From 75aac1bd573b7aaa3f812a869e0f8ab51a62d778 Mon Sep 17 00:00:00 2001
From: "deepin-community-bot[bot]"
<156989552+deepin-community-bot[bot]@users.noreply.github.com>
Date: Thu, 26 Dec 2024 13:02:47 +0000
Subject: [PATCH] feat: update aiohappyeyeballs to 2.4.4-2
---
.all-contributorsrc | 15 +
.copier-answers.yml | 20 +
.editorconfig | 21 +
.gitignore | 140 ++
.gitpod.yml | 8 +
.idea/aiohappyeyeballs.iml | 9 +
.idea/watcherTasks.xml | 65 +
.idea/workspace.xml | 32 +
.pre-commit-config.yaml | 54 +
.readthedocs.yml | 22 +
CHANGELOG.md | 242 +++
CONTRIBUTING.md | 117 ++
LICENSE | 279 +++
README.md | 97 +-
commitlint.config.js | 8 +
debian/changelog | 54 +-
debian/compat | 1 -
debian/control | 85 +-
debian/copyright | 80 +-
debian/docs | 1 +
debian/gbp.conf | 2 +
.../patches/0001-remove-README-badges.patch | 50 +
debian/patches/series | 1 +
debian/python-aiohappyeyeballs-doc.doc-base | 8 +
debian/python-aiohappyeyeballs-doc.docs | 1 +
debian/python-aiohappyeyeballs-doc.links | 1 +
debian/rules | 25 +-
debian/tests/control | 6 +
debian/tests/run-tests | 6 +
debian/upstream/metadata | 4 +
debian/watch | 2 +
docs/Makefile | 24 +
docs/_static/.gitkeep | 0
docs/changelog.md | 5 +
docs/conf.py | 33 +
docs/contributing.md | 5 +
docs/index.md | 21 +
docs/installation.md | 11 +
docs/make.bat | 35 +
docs/usage.md | 28 +
poetry.lock | 1348 ++++++++++++
pyproject.toml | 159 ++
renovate.json | 3 +
setup.py | 9 +
src/aiohappyeyeballs/__init__.py | 13 +
src/aiohappyeyeballs/_staggered.py | 202 ++
src/aiohappyeyeballs/impl.py | 221 ++
src/aiohappyeyeballs/py.typed | 0
src/aiohappyeyeballs/types.py | 12 +
src/aiohappyeyeballs/utils.py | 97 +
templates/CHANGELOG.md.j2 | 17 +
tests/__init__.py | 0
tests/conftest.py | 32 +
tests/test_impl.py | 1870 +++++++++++++++++
tests/test_init.py | 5 +
tests/test_staggered.py | 86 +
tests/test_staggered_cpython.py | 146 ++
...st_staggered_cpython_eager_task_factory.py | 96 +
tests/test_utils.py | 185 ++
59 files changed, 6081 insertions(+), 38 deletions(-)
create mode 100644 .all-contributorsrc
create mode 100644 .copier-answers.yml
create mode 100644 .editorconfig
create mode 100644 .gitignore
create mode 100644 .gitpod.yml
create mode 100644 .idea/aiohappyeyeballs.iml
create mode 100644 .idea/watcherTasks.xml
create mode 100644 .idea/workspace.xml
create mode 100644 .pre-commit-config.yaml
create mode 100644 .readthedocs.yml
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 commitlint.config.js
delete mode 100644 debian/compat
create mode 100644 debian/docs
create mode 100644 debian/gbp.conf
create mode 100644 debian/patches/0001-remove-README-badges.patch
create mode 100644 debian/patches/series
create mode 100644 debian/python-aiohappyeyeballs-doc.doc-base
create mode 100644 debian/python-aiohappyeyeballs-doc.docs
create mode 100644 debian/python-aiohappyeyeballs-doc.links
create mode 100644 debian/tests/control
create mode 100755 debian/tests/run-tests
create mode 100644 debian/upstream/metadata
create mode 100644 debian/watch
create mode 100644 docs/Makefile
create mode 100644 docs/_static/.gitkeep
create mode 100644 docs/changelog.md
create mode 100644 docs/conf.py
create mode 100644 docs/contributing.md
create mode 100644 docs/index.md
create mode 100644 docs/installation.md
create mode 100644 docs/make.bat
create mode 100644 docs/usage.md
create mode 100644 poetry.lock
create mode 100644 pyproject.toml
create mode 100644 renovate.json
create mode 100644 setup.py
create mode 100644 src/aiohappyeyeballs/__init__.py
create mode 100644 src/aiohappyeyeballs/_staggered.py
create mode 100644 src/aiohappyeyeballs/impl.py
create mode 100644 src/aiohappyeyeballs/py.typed
create mode 100644 src/aiohappyeyeballs/types.py
create mode 100644 src/aiohappyeyeballs/utils.py
create mode 100644 templates/CHANGELOG.md.j2
create mode 100644 tests/__init__.py
create mode 100644 tests/conftest.py
create mode 100644 tests/test_impl.py
create mode 100644 tests/test_init.py
create mode 100644 tests/test_staggered.py
create mode 100644 tests/test_staggered_cpython.py
create mode 100644 tests/test_staggered_cpython_eager_task_factory.py
create mode 100644 tests/test_utils.py
diff --git a/.all-contributorsrc b/.all-contributorsrc
new file mode 100644
index 0000000..83d6c10
--- /dev/null
+++ b/.all-contributorsrc
@@ -0,0 +1,15 @@
+{
+ "projectName": "aiohappyeyeballs",
+ "projectOwner": "aio-libs",
+ "repoType": "github",
+ "repoHost": "https://github.com",
+ "files": [
+ "README.md"
+ ],
+ "imageSize": 80,
+ "commit": true,
+ "commitConvention": "angular",
+ "contributors": [],
+ "contributorsPerLine": 7,
+ "skipCi": true
+}
diff --git a/.copier-answers.yml b/.copier-answers.yml
new file mode 100644
index 0000000..7b81b25
--- /dev/null
+++ b/.copier-answers.yml
@@ -0,0 +1,20 @@
+# Changes here will be overwritten by Copier
+_commit: b09ed7d
+_src_path: gh:browniebroke/pypackage-template
+add_me_as_contributor: false
+copyright_year: '2023'
+documentation: true
+email: nick@koston.org
+full_name: J. Nick Koston
+github_username: aio-libs
+has_cli: false
+initial_commit: true
+open_source_license: PSF-2.0
+package_name: aiohappyeyeballs
+project_name: aiohappyeyeballs
+project_short_description: Happy Eyeballs
+project_slug: aiohappyeyeballs
+run_poetry_install: true
+setup_github: true
+setup_pre_commit: true
+
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..d4a2c44
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,21 @@
+# http://editorconfig.org
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+charset = utf-8
+end_of_line = lf
+
+[*.bat]
+indent_style = tab
+end_of_line = crlf
+
+[LICENSE]
+insert_final_newline = false
+
+[Makefile]
+indent_style = tab
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08cfdfd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,140 @@
+# Created by .ignore support plugin (hsz.mobi)
+### Python template
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder {{package_name}} settings
+.spyderproject
+.spyproject
+
+# Rope {{package_name}} settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
diff --git a/.gitpod.yml b/.gitpod.yml
new file mode 100644
index 0000000..450add9
--- /dev/null
+++ b/.gitpod.yml
@@ -0,0 +1,8 @@
+tasks:
+ - command: |
+ pip install poetry
+ PIP_USER=false poetry install
+ - command: |
+ pip install pre-commit
+ pre-commit install
+ PIP_USER=false pre-commit install-hooks
diff --git a/.idea/aiohappyeyeballs.iml b/.idea/aiohappyeyeballs.iml
new file mode 100644
index 0000000..a46d9bb
--- /dev/null
+++ b/.idea/aiohappyeyeballs.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/.idea/watcherTasks.xml b/.idea/watcherTasks.xml
new file mode 100644
index 0000000..22b6eba
--- /dev/null
+++ b/.idea/watcherTasks.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..f2e6d22
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..ca88fd9
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,54 @@
+# See https://pre-commit.com for more information
+# See https://pre-commit.com/hooks.html for more hooks
+exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc"
+default_stages: [pre-commit]
+
+ci:
+ autofix_commit_msg: "chore(pre-commit.ci): auto fixes"
+ autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate"
+
+repos:
+ - repo: https://github.com/commitizen-tools/commitizen
+ rev: v3.31.0
+ hooks:
+ - id: commitizen
+ stages: [commit-msg]
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: debug-statements
+ - id: check-builtin-literals
+ - id: check-case-conflict
+ - id: check-docstring-first
+ - id: check-json
+ - id: check-toml
+ - id: check-xml
+ - id: check-yaml
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - repo: https://github.com/python-poetry/poetry
+ rev: 1.8.0
+ hooks:
+ - id: poetry-check
+ - repo: https://github.com/pre-commit/mirrors-prettier
+ rev: v4.0.0-alpha.8
+ hooks:
+ - id: prettier
+ args: ["--tab-width", "2"]
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.8.0
+ hooks:
+ - id: ruff
+ args: [--fix, --exit-non-zero-on-fix]
+ # Run the formatter.
+ - id: ruff-format
+ - repo: https://github.com/codespell-project/codespell
+ rev: v2.3.0
+ hooks:
+ - id: codespell
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.13.0
+ hooks:
+ - id: mypy
+ additional_dependencies: []
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000..37d2e0d
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,22 @@
+# Read the Docs configuration file
+# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
+
+# Required
+version: 2
+
+# Set the version of Python and other tools you might need
+build:
+ os: ubuntu-20.04
+ tools:
+ python: "3.12"
+ jobs:
+ post_create_environment:
+ # Install poetry
+ - pip install poetry
+ post_install:
+ # Install dependencies, reusing RTD virtualenv
+ - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs
+
+# Build documentation in the docs directory with Sphinx
+sphinx:
+ configuration: docs/conf.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..51e8b63
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,242 @@
+# Changelog
+
+## v2.4.4 (2024-11-30)
+
+### Fix
+
+- Handle oserror on failure to close socket instead of raising indexerror (#114) ([`c542f68`](https://github.com/aio-libs/aiohappyeyeballs/commit/c542f684d329fed04093caa2b31d8f7f6e0e0949))
+
+## v2.4.3 (2024-09-30)
+
+### Fix
+
+- Rewrite staggered_race to be race safe (#101) ([`9db617a`](https://github.com/aio-libs/aiohappyeyeballs/commit/9db617a982ee27994bf13c805f9c4f054f05de47))
+- Re-raise runtimeerror when uvloop raises runtimeerror during connect (#105) ([`c8f1fa9`](https://github.com/aio-libs/aiohappyeyeballs/commit/c8f1fa93d698f216f84de7074a6282777fbf0439))
+
+## v2.4.2 (2024-09-27)
+
+### Fix
+
+- Copy staggered from standard lib for python 3.12+ (#95) ([`c5a4023`](https://github.com/aio-libs/aiohappyeyeballs/commit/c5a4023d904b3e72f30b8a9f56913894dda4c9d0))
+
+## v2.4.1 (2024-09-26)
+
+### Fix
+
+- Avoid passing loop to staggered.staggered_race (#94) ([`5f80b79`](https://github.com/aio-libs/aiohappyeyeballs/commit/5f80b7951f32d727039d9db776a17a6eba8877cd))
+
+## v2.4.0 (2024-08-19)
+
+### Feature
+
+- Add support for python 3.13 (#86) ([`4f2152f`](https://github.com/aio-libs/aiohappyeyeballs/commit/4f2152fbb6b1d915c2fd68219339d998c47a71f9))
+
+### Documentation
+
+- Fix a trivial typo in readme.md (#84) ([`f5ae7d4`](https://github.com/aio-libs/aiohappyeyeballs/commit/f5ae7d4bce04ee0645257ac828745a3b989ef149))
+
+## v2.3.7 (2024-08-17)
+
+### Fix
+
+- Correct classifier for license python-2.0.1 (#83) ([`186be05`](https://github.com/aio-libs/aiohappyeyeballs/commit/186be056ea441bb3fa7620864f46c6f8431f3a34))
+
+## v2.3.6 (2024-08-16)
+
+### Fix
+
+- Adjust license to python-2.0.1 (#82) ([`30a2dc5`](https://github.com/aio-libs/aiohappyeyeballs/commit/30a2dc57c49d1000ebdafa8c81ecf4f79e35c9f3))
+
+## v2.3.5 (2024-08-07)
+
+### Fix
+
+- Remove upper bound on python requirement (#74) ([`0de1e53`](https://github.com/aio-libs/aiohappyeyeballs/commit/0de1e534fc5b7526e11bf203ab09b95b13f3070b))
+- Preserve errno if all exceptions have the same errno (#77) ([`7bbb2bf`](https://github.com/aio-libs/aiohappyeyeballs/commit/7bbb2bf0feb3994953a52a1d606e682acad49cb8))
+- Adjust license classifier to better reflect license terms (#78) ([`56e7ba6`](https://github.com/aio-libs/aiohappyeyeballs/commit/56e7ba612c5029364bb960b07022a2b720f0a967))
+
+### Documentation
+
+- Add link to happy eyeballs explanation (#73) ([`077710c`](https://github.com/aio-libs/aiohappyeyeballs/commit/077710c150b6c762ffe408e0ad418c506a2d6f31))
+
+## v2.3.4 (2024-07-31)
+
+### Fix
+
+- Add missing asyncio to fix truncated package description (#67) ([`2644df1`](https://github.com/aio-libs/aiohappyeyeballs/commit/2644df179e21e4513da857f2aea2aa64a3fb6316))
+
+## v2.3.3 (2024-07-31)
+
+### Fix
+
+- Add missing python version classifiers (#65) ([`489016f`](https://github.com/aio-libs/aiohappyeyeballs/commit/489016feb53d4fd5f9880f27dc40a5198d5b0be2))
+- Update classifiers to include license (#60) ([`a746c29`](https://github.com/aio-libs/aiohappyeyeballs/commit/a746c296b324407efef272f422a990587b9d6057))
+- Workaround broken `asyncio.staggered` on python < 3.8.2 (#61) ([`b16f107`](https://github.com/aio-libs/aiohappyeyeballs/commit/b16f107d9493817247c27ab83522901f086a13b5))
+- Include tests in the source distribution package (#62) ([`53053b6`](https://github.com/aio-libs/aiohappyeyeballs/commit/53053b6a38ef868e0170940ced5e0611ebd1be4c))
+
+## v2.3.2 (2024-01-06)
+
+### Fix
+
+- Update urls for the new home for this library (#43) ([`c6d4358`](https://github.com/aio-libs/aiohappyeyeballs/commit/c6d43586d5ca56472892767d4a47d28348158544))
+
+## v2.3.1 (2023-12-14)
+
+### Fix
+
+- Remove test import from tests (#31) ([`c529b15`](https://github.com/aio-libs/aiohappyeyeballs/commit/c529b15fbead0aa5cde9dd5c460ff5abd15fc355))
+
+## v2.3.0 (2023-12-12)
+
+### Feature
+
+- Avoid _interleave_addrinfos when there is only a single addr_info (#29) ([`305f6f1`](https://github.com/aio-libs/aiohappyeyeballs/commit/305f6f13d028ab3ead7923870601175102c5970c))
+
+## v2.2.0 (2023-12-11)
+
+### Feature
+
+- Make interleave with pop_addr_infos_interleave optional to match cpython (#28) ([`adbc8ad`](https://github.com/aio-libs/aiohappyeyeballs/commit/adbc8adfaa44349ca83966787400413668f0b4b6))
+
+## v2.1.0 (2023-12-11)
+
+### Feature
+
+- Add addr_to_addr_info util for converting addr to addr_infos (#27) ([`2e25a98`](https://github.com/aio-libs/aiohappyeyeballs/commit/2e25a98f2339d84bc7951ad17f0b38c104a97a71))
+
+## v2.0.0 (2023-12-10)
+
+### Breaking
+
+- Require the full address tuple for the remove_addr_infos util (#26) ([`d7e5df1`](https://github.com/aio-libs/aiohappyeyeballs/commit/d7e5df12a01838e81729af4c49938e98b3407e03))
+
+## v1.8.1 (2023-12-10)
+
+### Fix
+
+- Move types into a single file (#24) ([`8d4cfee`](https://github.com/aio-libs/aiohappyeyeballs/commit/8d4cfeeaa7862e028e941c49f8c84dcee0b9b1ac))
+
+## v1.8.0 (2023-12-10)
+
+### Feature
+
+- Add utils (#23) ([`d89311d`](https://github.com/aio-libs/aiohappyeyeballs/commit/d89311d1a433dde75863019a08717a531f68befa))
+
+## v1.7.0 (2023-12-09)
+
+### Fix
+
+- License should be psf-2.0 (#22) ([`ca9c1fc`](https://github.com/aio-libs/aiohappyeyeballs/commit/ca9c1fca4d63c54855fbe582132b5dcb229c7591))
+
+### Feature
+
+- Add some more examples to the docs (#21) ([`6cd0b5d`](https://github.com/aio-libs/aiohappyeyeballs/commit/6cd0b5d10357a9d20fc5ee1c96db18c6994cd8fc))
+
+## v1.6.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for multiple and same exceptions (#20) ([`2781b87`](https://github.com/aio-libs/aiohappyeyeballs/commit/2781b87c56aa1c08345d91dce5c1642f2b3e396d))
+
+## v1.5.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for setblocking failing (#19) ([`f759a08`](https://github.com/aio-libs/aiohappyeyeballs/commit/f759a08180f0237cb68d353090f7ba0efe625074))
+- Add cover for passing the loop (#18) ([`2d26911`](https://github.com/aio-libs/aiohappyeyeballs/commit/2d26911e9237691c168a705b2d6be2a68fa8b7ac))
+
+## v1.4.1 (2023-12-09)
+
+### Fix
+
+- Ensure exception error is stringified (#17) ([`747cf1d`](https://github.com/aio-libs/aiohappyeyeballs/commit/747cf1d231dc427b79ff1f8128779413a50be5d8))
+
+## v1.4.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for unexpected exception (#16) ([`bad4874`](https://github.com/aio-libs/aiohappyeyeballs/commit/bad48745d3621fcbbe559d55180dc5f5856dc0fa))
+
+## v1.3.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for bind failure with local addresses (#15) ([`f71ec23`](https://github.com/aio-libs/aiohappyeyeballs/commit/f71ec23228d4dad4bc2c3a6630e6e4361b54df44))
+
+## v1.2.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for passing local addresses (#14) ([`72a92e3`](https://github.com/aio-libs/aiohappyeyeballs/commit/72a92e3a599cde082856354e806a793f2b9eff62))
+
+## v1.1.0 (2023-12-09)
+
+### Feature
+
+- Add example usage (#13) ([`707fddc`](https://github.com/aio-libs/aiohappyeyeballs/commit/707fddcd8e8aff27af2180af6271898003ca1782))
+
+## v1.0.0 (2023-12-09)
+
+### Breaking
+
+- Rename create_connection to start_connection (#12) ([`f8b6038`](https://github.com/aio-libs/aiohappyeyeballs/commit/f8b60383d9b9f013baf421ad4e4e183559b7a705))
+
+## v0.9.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for interleave (#11) ([`62817f1`](https://github.com/aio-libs/aiohappyeyeballs/commit/62817f1473bb5702f8fa9edc6f6b24139990cd01))
+
+## v0.8.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for multi ipv6 (#10) ([`6dc8f89`](https://github.com/aio-libs/aiohappyeyeballs/commit/6dc8f89ff99a38c8ecaf8045c9afbe683d6f2c6e))
+
+## v0.7.0 (2023-12-09)
+
+### Feature
+
+- Add coverage for ipv6 failure (#9) ([`7aee8f6`](https://github.com/aio-libs/aiohappyeyeballs/commit/7aee8f64064cfc8d79f385c4dfee45036aacd6fd))
+
+## v0.6.0 (2023-12-09)
+
+### Feature
+
+- Improve test coverage (#8) ([`afcfe5a`](https://github.com/aio-libs/aiohappyeyeballs/commit/afcfe5a350acc50a098009617511cd9d21b22f47))
+
+## v0.5.0 (2023-12-09)
+
+### Feature
+
+- Improve doc strings (#7) ([`3d5f7fd`](https://github.com/aio-libs/aiohappyeyeballs/commit/3d5f7fde55c4bdd4f5e6cff589ae9b47b279d663))
+
+## v0.4.0 (2023-12-09)
+
+### Feature
+
+- Add more tests (#6) ([`4428c07`](https://github.com/aio-libs/aiohappyeyeballs/commit/4428c0714e3e100605f940eb6adee2e86788b4db))
+
+## v0.3.0 (2023-12-09)
+
+### Feature
+
+- Optimize for single case (#5) ([`c7d72f3`](https://github.com/aio-libs/aiohappyeyeballs/commit/c7d72f3cdd13149319fc9e4848146d23bddc619b))
+
+## v0.2.0 (2023-12-09)
+
+### Feature
+
+- Optimize for single case (#4) ([`d371c46`](https://github.com/aio-libs/aiohappyeyeballs/commit/d371c4687d3b3861a4f0287ac5229853f895807b))
+
+## v0.1.0 (2023-12-09)
+
+### Feature
+
+- Init (#2) ([`c9a9099`](https://github.com/aio-libs/aiohappyeyeballs/commit/c9a90994a40d5f49cb37d3e2708db4b4278649ef))
+
+## v0.0.1 (2023-12-09)
+
+### Fix
+
+- Reserve name on pypi (#1) ([`2207f8d`](https://github.com/aio-libs/aiohappyeyeballs/commit/2207f8d361af4ec0b853b07fb743eb957a0b368a))
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bc0a695
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,117 @@
+# Contributing
+
+Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given.
+
+You can contribute in many ways:
+
+## Types of Contributions
+
+### Report Bugs
+
+Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include:
+
+- Your operating system name and version.
+- Any details about your local setup that might be helpful in troubleshooting.
+- Detailed steps to reproduce the bug.
+
+### Fix Bugs
+
+Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it.
+
+### Implement Features
+
+Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it.
+
+### Write Documentation
+
+aiohappyeyeballs could always use more documentation, whether as part of the official aiohappyeyeballs docs, in docstrings, or even on the web in blog posts, articles, and such.
+
+### Submit Feedback
+
+The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature:
+
+- Explain in detail how it would work.
+- Keep the scope as narrow as possible, to make it easier to implement.
+- Remember that this is a volunteer-driven project, and that contributions are welcome 😊
+
+## Get Started!
+
+Ready to contribute? Here's how to set yourself up for local development.
+
+1. Fork the repo on GitHub.
+
+2. Clone your fork locally:
+
+ ```shell
+ $ git clone git@github.com:your_name_here/aiohappyeyeballs.git
+ ```
+
+3. Install the project dependencies with [Poetry](https://python-poetry.org):
+
+ ```shell
+ $ poetry install
+ ```
+
+4. Create a branch for local development:
+
+ ```shell
+ $ git checkout -b name-of-your-bugfix-or-feature
+ ```
+
+ Now you can make your changes locally.
+
+5. When you're done making changes, check that your changes pass our tests:
+
+ ```shell
+ $ poetry run pytest
+ ```
+
+6. Linting is done through [pre-commit](https://pre-commit.com). Provided you have the tool installed globally, you can run them all as one-off:
+
+ ```shell
+ $ pre-commit run -a
+ ```
+
+ Or better, install the hooks once and have them run automatically each time you commit:
+
+ ```shell
+ $ pre-commit install
+ ```
+
+7. Commit your changes and push your branch to GitHub:
+
+ ```shell
+ $ git add .
+ $ git commit -m "feat(something): your detailed description of your changes"
+ $ git push origin name-of-your-bugfix-or-feature
+ ```
+
+ Note: the commit message should follow [the conventional commits](https://www.conventionalcommits.org). We run [`commitlint` on CI](https://github.com/marketplace/actions/commit-linter) to validate it, and if you've installed pre-commit hooks at the previous step, the message will be checked at commit time.
+
+8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed):
+
+ ```shell
+ $ gh pr create --fill
+ ```
+
+## Pull Request Guidelines
+
+We like to have the pull request open as soon as possible, that's a great place to discuss any piece of work, even unfinished. You can use draft pull request if it's still a work in progress. Here are a few guidelines to follow:
+
+1. Include tests for feature or bug fixes.
+2. Update the documentation for significant features.
+3. Ensure tests are passing on CI.
+
+## Tips
+
+To run a subset of tests:
+
+```shell
+$ pytest tests
+```
+
+## Making a new release
+
+The deployment should be automated and can be triggered from the Semantic Release workflow in GitHub. The next version will be based on [the commit logs](https://python-semantic-release.readthedocs.io/en/latest/commit-log-parsing.html#commit-log-parsing). This is done by [python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/index.html) via a GitHub action.
+
+[gh-issues]: https://github.com/aio-libs/aiohappyeyeballs/issues
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f26bcf4
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,279 @@
+A. HISTORY OF THE SOFTWARE
+==========================
+
+Python was created in the early 1990s by Guido van Rossum at Stichting
+Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
+as a successor of a language called ABC. Guido remains Python's
+principal author, although it includes many contributions from others.
+
+In 1995, Guido continued his work on Python at the Corporation for
+National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
+in Reston, Virginia where he released several versions of the
+software.
+
+In May 2000, Guido and the Python core development team moved to
+BeOpen.com to form the BeOpen PythonLabs team. In October of the same
+year, the PythonLabs team moved to Digital Creations, which became
+Zope Corporation. In 2001, the Python Software Foundation (PSF, see
+https://www.python.org/psf/) was formed, a non-profit organization
+created specifically to own Python-related Intellectual Property.
+Zope Corporation was a sponsoring member of the PSF.
+
+All Python releases are Open Source (see https://opensource.org for
+the Open Source Definition). Historically, most, but not all, Python
+releases have also been GPL-compatible; the table below summarizes
+the various releases.
+
+ Release Derived Year Owner GPL-
+ from compatible? (1)
+
+ 0.9.0 thru 1.2 1991-1995 CWI yes
+ 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
+ 1.6 1.5.2 2000 CNRI no
+ 2.0 1.6 2000 BeOpen.com no
+ 1.6.1 1.6 2001 CNRI yes (2)
+ 2.1 2.0+1.6.1 2001 PSF no
+ 2.0.1 2.0+1.6.1 2001 PSF yes
+ 2.1.1 2.1+2.0.1 2001 PSF yes
+ 2.1.2 2.1.1 2002 PSF yes
+ 2.1.3 2.1.2 2002 PSF yes
+ 2.2 and above 2.1.1 2001-now PSF yes
+
+Footnotes:
+
+(1) GPL-compatible doesn't mean that we're distributing Python under
+ the GPL. All Python licenses, unlike the GPL, let you distribute
+ a modified version without making your changes open source. The
+ GPL-compatible licenses make it possible to combine Python with
+ other software that is released under the GPL; the others don't.
+
+(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
+ because its license has a choice of law clause. According to
+ CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
+ is "not incompatible" with the GPL.
+
+Thanks to the many outside volunteers who have worked under Guido's
+direction to make these releases possible.
+
+
+B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
+===============================================================
+
+Python software and documentation are licensed under the
+Python Software Foundation License Version 2.
+
+Starting with Python 3.8.6, examples, recipes, and other code in
+the documentation are dual licensed under the PSF License Version 2
+and the Zero-Clause BSD license.
+
+Some software incorporated into Python is under different licenses.
+The licenses are listed with code falling under that license.
+
+
+PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
+--------------------------------------------
+
+1. This LICENSE AGREEMENT is between the Python Software Foundation
+("PSF"), and the Individual or Organization ("Licensee") accessing and
+otherwise using this software ("Python") in source or binary form and
+its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, PSF hereby
+grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
+analyze, test, perform and/or display publicly, prepare derivative works,
+distribute, and otherwise use Python alone or in any derivative version,
+provided, however, that PSF's License Agreement and PSF's notice of copyright,
+i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
+2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
+All Rights Reserved" are retained in Python alone or in any derivative version
+prepared by Licensee.
+
+3. In the event Licensee prepares a derivative work that is based on
+or incorporates Python or any part thereof, and wants to make
+the derivative work available to others as provided herein, then
+Licensee hereby agrees to include in any such work a brief summary of
+the changes made to Python.
+
+4. PSF is making Python available to Licensee on an "AS IS"
+basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
+OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+7. Nothing in this License Agreement shall be deemed to create any
+relationship of agency, partnership, or joint venture between PSF and
+Licensee. This License Agreement does not grant permission to use PSF
+trademarks or trade name in a trademark sense to endorse or promote
+products or services of Licensee, or any third party.
+
+8. By copying, installing or otherwise using Python, Licensee
+agrees to be bound by the terms and conditions of this License
+Agreement.
+
+
+BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
+-------------------------------------------
+
+BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
+
+1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
+office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
+Individual or Organization ("Licensee") accessing and otherwise using
+this software in source or binary form and its associated
+documentation ("the Software").
+
+2. Subject to the terms and conditions of this BeOpen Python License
+Agreement, BeOpen hereby grants Licensee a non-exclusive,
+royalty-free, world-wide license to reproduce, analyze, test, perform
+and/or display publicly, prepare derivative works, distribute, and
+otherwise use the Software alone or in any derivative version,
+provided, however, that the BeOpen Python License is retained in the
+Software, alone or in any derivative version prepared by Licensee.
+
+3. BeOpen is making the Software available to Licensee on an "AS IS"
+basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
+SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
+AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
+DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+5. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+6. This License Agreement shall be governed by and interpreted in all
+respects by the law of the State of California, excluding conflict of
+law provisions. Nothing in this License Agreement shall be deemed to
+create any relationship of agency, partnership, or joint venture
+between BeOpen and Licensee. This License Agreement does not grant
+permission to use BeOpen trademarks or trade names in a trademark
+sense to endorse or promote products or services of Licensee, or any
+third party. As an exception, the "BeOpen Python" logos available at
+http://www.pythonlabs.com/logos.html may be used according to the
+permissions granted on that web page.
+
+7. By copying, installing or otherwise using the software, Licensee
+agrees to be bound by the terms and conditions of this License
+Agreement.
+
+
+CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
+---------------------------------------
+
+1. This LICENSE AGREEMENT is between the Corporation for National
+Research Initiatives, having an office at 1895 Preston White Drive,
+Reston, VA 20191 ("CNRI"), and the Individual or Organization
+("Licensee") accessing and otherwise using Python 1.6.1 software in
+source or binary form and its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, CNRI
+hereby grants Licensee a nonexclusive, royalty-free, world-wide
+license to reproduce, analyze, test, perform and/or display publicly,
+prepare derivative works, distribute, and otherwise use Python 1.6.1
+alone or in any derivative version, provided, however, that CNRI's
+License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
+1995-2001 Corporation for National Research Initiatives; All Rights
+Reserved" are retained in Python 1.6.1 alone or in any derivative
+version prepared by Licensee. Alternately, in lieu of CNRI's License
+Agreement, Licensee may substitute the following text (omitting the
+quotes): "Python 1.6.1 is made available subject to the terms and
+conditions in CNRI's License Agreement. This Agreement together with
+Python 1.6.1 may be located on the internet using the following
+unique, persistent identifier (known as a handle): 1895.22/1013. This
+Agreement may also be obtained from a proxy server on the internet
+using the following URL: http://hdl.handle.net/1895.22/1013".
+
+3. In the event Licensee prepares a derivative work that is based on
+or incorporates Python 1.6.1 or any part thereof, and wants to make
+the derivative work available to others as provided herein, then
+Licensee hereby agrees to include in any such work a brief summary of
+the changes made to Python 1.6.1.
+
+4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
+basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
+DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
+INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
+OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+breach of its terms and conditions.
+
+7. This License Agreement shall be governed by the federal
+intellectual property law of the United States, including without
+limitation the federal copyright law, and, to the extent such
+U.S. federal law does not apply, by the law of the Commonwealth of
+Virginia, excluding Virginia's conflict of law provisions.
+Notwithstanding the foregoing, with regard to derivative works based
+on Python 1.6.1 that incorporate non-separable material that was
+previously distributed under the GNU General Public License (GPL), the
+law of the Commonwealth of Virginia shall govern this License
+Agreement only as to issues arising under or with respect to
+Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
+License Agreement shall be deemed to create any relationship of
+agency, partnership, or joint venture between CNRI and Licensee. This
+License Agreement does not grant permission to use CNRI trademarks or
+trade name in a trademark sense to endorse or promote products or
+services of Licensee, or any third party.
+
+8. By clicking on the "ACCEPT" button where indicated, or by copying,
+installing or otherwise using Python 1.6.1, Licensee agrees to be
+bound by the terms and conditions of this License Agreement.
+
+ ACCEPT
+
+
+CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
+--------------------------------------------------
+
+Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
+The Netherlands. All rights reserved.
+
+Permission to use, copy, modify, and distribute this software and its
+documentation for any purpose and without fee is hereby granted,
+provided that the above copyright notice appear in all copies and that
+both that copyright notice and this permission notice appear in
+supporting documentation, and that the name of Stichting Mathematisch
+Centrum or CWI not be used in advertising or publicity pertaining to
+distribution of the software without specific, written prior
+permission.
+
+STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
+THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
+FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
+----------------------------------------------------------------------
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/README.md b/README.md
index 9ebb840..a1cbd99 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,96 @@
-# template-repository
\ No newline at end of file
+# aiohappyeyeballs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+---
+
+**Documentation**: https://aiohappyeyeballs.readthedocs.io
+
+**Source Code**: https://github.com/aio-libs/aiohappyeyeballs
+
+---
+
+[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
+([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
+
+## Use case
+
+This library exists to allow connecting with
+[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
+([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
+when you
+already have a list of addrinfo and not a DNS name.
+
+The stdlib version of `loop.create_connection()`
+will only work when you pass in an unresolved name which
+is not a good fit when using DNS caching or resolving
+names via another method such as `zeroconf`.
+
+## Installation
+
+Install this via pip (or your favourite package manager):
+
+`pip install aiohappyeyeballs`
+
+## License
+
+[aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE)
+
+## Example usage
+
+```python
+
+addr_infos = await loop.getaddrinfo("example.org", 80)
+
+socket = await start_connection(addr_infos)
+socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
+
+transport, protocol = await loop.create_connection(
+ MyProtocol, sock=socket, ...)
+
+# Remove the first address for each family from addr_info
+pop_addr_infos_interleave(addr_info, 1)
+
+# Remove all matching address from addr_info
+remove_addr_infos(addr_info, "dead::beef::")
+
+# Convert a local_addr to local_addr_infos
+local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
+```
+
+## Credits
+
+This package contains code from cpython and is licensed under the same terms as cpython itself.
+
+This package was created with
+[Copier](https://copier.readthedocs.io/) and the
+[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
+project template.
diff --git a/commitlint.config.js b/commitlint.config.js
new file mode 100644
index 0000000..8b82768
--- /dev/null
+++ b/commitlint.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ extends: ["@commitlint/config-conventional"],
+ rules: {
+ "header-max-length": [0, "always", Infinity],
+ "body-max-line-length": [0, "always", Infinity],
+ "footer-max-line-length": [0, "always", Infinity],
+ },
+};
diff --git a/debian/changelog b/debian/changelog
index bad88e2..e60eedf 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,5 +1,53 @@
-template-repository (1.0-1) unstable; urgency=medium
+aiohappyeyeballs (2.4.4-2) unstable; urgency=medium
- * Initial release
+ * Skip test which needs network access.
+ * Simplify debian/watch.
- -- Tsic404 Sat, 28 Jan 2023 13:46:49 +0800
+ -- Edward Betts Wed, 18 Dec 2024 09:01:25 +0000
+
+aiohappyeyeballs (2.4.4-1) unstable; urgency=medium
+
+ * New upstream release.
+
+ -- Edward Betts Thu, 05 Dec 2024 10:32:17 +0000
+
+aiohappyeyeballs (2.4.3-1) unstable; urgency=medium
+
+ * New upstream release.
+ * Refresh remove README badges patch.
+
+ -- Edward Betts Fri, 04 Oct 2024 06:06:08 +0100
+
+aiohappyeyeballs (2.4.2-1) unstable; urgency=medium
+
+ * New upstream release.
+
+ -- Edward Betts Mon, 30 Sep 2024 12:27:54 +0100
+
+aiohappyeyeballs (2.4.0-1) unstable; urgency=medium
+
+ * New upstream release.
+ * Move packages required for tests to Build-Depends-Indep.
+
+ -- Edward Betts Tue, 20 Aug 2024 22:27:07 +0100
+
+aiohappyeyeballs (2.3.5-1) unstable; urgency=medium
+
+ * New upstream release.
+ * Update Standards-Version.
+ * Add missing Build-Depends for python3-pytest-asyncio.
+ * Update autopkgtest to run asyncio tests.
+
+ -- Edward Betts Mon, 12 Aug 2024 12:07:43 +0100
+
+aiohappyeyeballs (2.3.2-2) unstable; urgency=medium
+
+ * Source-only upload to allow package to migrate to testing.
+
+ -- Edward Betts Sun, 26 May 2024 14:42:50 +0200
+
+aiohappyeyeballs (2.3.2-1) unstable; urgency=medium
+
+ * Initial release. (Closes: #1071486)
+
+ -- Edward Betts Tue, 21 May 2024 13:59:20 +0200
diff --git a/debian/compat b/debian/compat
deleted file mode 100644
index b4de394..0000000
--- a/debian/compat
+++ /dev/null
@@ -1 +0,0 @@
-11
diff --git a/debian/control b/debian/control
index cb7c4a0..41c267c 100644
--- a/debian/control
+++ b/debian/control
@@ -1,15 +1,74 @@
-Source: template-repository
-Section: unknown
+Source: aiohappyeyeballs
+Maintainer: Debian Python Team
+Uploaders:
+ Edward Betts ,
+Section: python
Priority: optional
-Maintainer: Tsic404
-Build-Depends: debhelper (>= 11)
-Standards-Version: 4.1.3
-Homepage: https://github.com/deepin-community/template-repository
-#Vcs-Browser: https://salsa.debian.org/debian/deepin-community-template-repository
-#Vcs-Git: https://salsa.debian.org/debian/deepin-community-template-repository.git
+Build-Depends:
+ debhelper-compat (= 13),
+ dh-sequence-python3,
+ dh-sequence-sphinxdoc ,
+ pybuild-plugin-pyproject,
+ python3-all,
+ python3-poetry-core,
+Build-Depends-Indep:
+ furo ,
+ python3-myst-parser ,
+ python3-pytest ,
+ python3-pytest-asyncio ,
+ python3-pytest-cov ,
+Rules-Requires-Root: no
+Standards-Version: 4.7.0
+Homepage: https://github.com/aio-libs/aiohappyeyeballs
+Vcs-Browser: https://salsa.debian.org/python-team/packages/aiohappyeyeballs
+Vcs-Git: https://salsa.debian.org/python-team/packages/aiohappyeyeballs.git
-Package: template-repository
-Architecture: any
-Depends: ${shlibs:Depends}, ${misc:Depends}
-Description:
-
+Package: python3-aiohappyeyeballs
+Architecture: all
+Depends:
+ ${misc:Depends},
+ ${python3:Depends},
+Description: Happy Eyeballs connection helper for asyncio
+ Implements the Happy Eyeballs algorithm for asyncio, facilitating rapid
+ connection establishment by attempting both IPv4 and IPv6 connections
+ simultaneously. This approach ensures that the connection is established
+ quickly and efficiently, even if one protocol is slower or unavailable.
+ .
+ Happy Eyeballs, also known as Fast Fallback, addresses the problem of
+ connectivity issues in dual-stack applications (supporting both IPv4 and
+ IPv6). By attempting both protocols in parallel and preferring IPv6, it
+ minimizes delays caused by IPv6 brokenness and enhances user experience
+ by promptly selecting the most responsive connection.
+ .
+ This library is particularly useful when using DNS caching or resolving
+ names through methods other than traditional DNS, such as zeroconf. It
+ allows for creating connections using pre-resolved addrinfo, bypassing
+ the limitations of the standard `loop.create_connection()` method which
+ requires unresolved names.
+
+Package: python-aiohappyeyeballs-doc
+Section: doc
+Architecture: all
+Multi-Arch: foreign
+Depends:
+ ${misc:Depends},
+ ${sphinxdoc:Depends},
+Description: Happy Eyeballs connection helper for asyncio (Documentation)
+ Implements the Happy Eyeballs algorithm for asyncio, facilitating rapid
+ connection establishment by attempting both IPv4 and IPv6 connections
+ simultaneously. This approach ensures that the connection is established
+ quickly and efficiently, even if one protocol is slower or unavailable.
+ .
+ Happy Eyeballs, also known as Fast Fallback, addresses the problem of
+ connectivity issues in dual-stack applications (supporting both IPv4 and
+ IPv6). By attempting both protocols in parallel and preferring IPv6, it
+ minimizes delays caused by IPv6 brokenness and enhances user experience
+ by promptly selecting the most responsive connection.
+ .
+ This library is particularly useful when using DNS caching or resolving
+ names through methods other than traditional DNS, such as zeroconf. It
+ allows for creating connections using pre-resolved addrinfo, bypassing
+ the limitations of the standard `loop.create_connection()` method which
+ requires unresolved names.
+ .
+ This package contains the documentation.
diff --git a/debian/copyright b/debian/copyright
index f5c805e..9909852 100644
--- a/debian/copyright
+++ b/debian/copyright
@@ -1,22 +1,64 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
-Upstream-Name: template-repository
-Source: https://github.com/deepin-community/template-repository
+Upstream-Name: aiohappyeyeballs
+Upstream-Contact: J. Nick Koston
+Source: https://github.com/aio-libs/aiohappyeyeballs
Files: *
-Copyright: 2023 Tsic404
-License: GPL-2+
- This package is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
- .
- This package is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
- .
- You should have received a copy of the GNU General Public License
- along with this program. If not, see
- .
- On Debian systems, the complete text of the GNU General
- Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
+Copyright: 2023 J. Nick Koston
+License: PSF-2.0
+
+Files: debian/*
+Copyright: 2024 Edward Betts
+License: PSF-2.0
+
+
+License: PSF-2.0
+ PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
+ --------------------------------------------
+ .
+ 1. This LICENSE AGREEMENT is between the Python Software Foundation
+ ("PSF"), and the Individual or Organization ("Licensee") accessing and
+ otherwise using this software ("Python") in source or binary form and
+ its associated documentation.
+ .
+ 2. Subject to the terms and conditions of this License Agreement, PSF
+ hereby grants Licensee a nonexclusive, royalty-free, world-wide
+ license to reproduce, analyze, test, perform and/or display publicly,
+ prepare derivative works, distribute, and otherwise use Python alone
+ or in any derivative version, provided, however, that PSF's License
+ Agreement and PSF's notice of copyright, i.e., "Copyright (c) 2001,
+ 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012,
+ 2013, 2014 Python Software Foundation; All Rights Reserved" are
+ retained in Python alone or in any derivative version prepared by
+ Licensee.
+ .
+ 3. In the event Licensee prepares a derivative work that is based on
+ or incorporates Python or any part thereof, and wants to make
+ the derivative work available to others as provided herein, then
+ Licensee hereby agrees to include in any such work a brief summary of
+ the changes made to Python.
+ .
+ 4. PSF is making Python available to Licensee on an "AS IS"
+ basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+ IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
+ DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+ FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
+ INFRINGE ANY THIRD PARTY RIGHTS.
+ .
+ 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+ FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+ A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
+ OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+ .
+ 6. This License Agreement will automatically terminate upon a material
+ breach of its terms and conditions.
+ .
+ 7. Nothing in this License Agreement shall be deemed to create any
+ relationship of agency, partnership, or joint venture between PSF and
+ Licensee. This License Agreement does not grant permission to use PSF
+ trademarks or trade name in a trademark sense to endorse or promote
+ products or services of Licensee, or any third party.
+ .
+ 8. By copying, installing or otherwise using Python, Licensee
+ agrees to be bound by the terms and conditions of this License
+ Agreement.
diff --git a/debian/docs b/debian/docs
new file mode 100644
index 0000000..b43bf86
--- /dev/null
+++ b/debian/docs
@@ -0,0 +1 @@
+README.md
diff --git a/debian/gbp.conf b/debian/gbp.conf
new file mode 100644
index 0000000..3879982
--- /dev/null
+++ b/debian/gbp.conf
@@ -0,0 +1,2 @@
+[DEFAULT]
+debian-branch=debian/master
diff --git a/debian/patches/0001-remove-README-badges.patch b/debian/patches/0001-remove-README-badges.patch
new file mode 100644
index 0000000..c36d110
--- /dev/null
+++ b/debian/patches/0001-remove-README-badges.patch
@@ -0,0 +1,50 @@
+From: Edward Betts
+Date: Sat, 5 Oct 2024 09:05:52 +0100
+Subject: remove README badges
+
+---
+ README.md | 32 --------------------------------
+ 1 file changed, 32 deletions(-)
+
+diff --git a/README.md b/README.md
+index a1cbd99..4df47d6 100644
+--- a/README.md
++++ b/README.md
+@@ -1,37 +1,5 @@
+ # aiohappyeyeballs
+
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+-
+----
+-
+ **Documentation**: https://aiohappyeyeballs.readthedocs.io
+
+ **Source Code**: https://github.com/aio-libs/aiohappyeyeballs
diff --git a/debian/patches/series b/debian/patches/series
new file mode 100644
index 0000000..fde6d53
--- /dev/null
+++ b/debian/patches/series
@@ -0,0 +1 @@
+0001-remove-README-badges.patch
diff --git a/debian/python-aiohappyeyeballs-doc.doc-base b/debian/python-aiohappyeyeballs-doc.doc-base
new file mode 100644
index 0000000..70c9b82
--- /dev/null
+++ b/debian/python-aiohappyeyeballs-doc.doc-base
@@ -0,0 +1,8 @@
+Document: python-aiohappyeyeballs-doc
+Title: Happy Eyeballs connection helper for asyncio Documentation
+Author: J. Nick Koston
+Section: Programming/Python
+
+Format: HTML
+Index: /usr/share/doc/python3-aiohappyeyeballs/html/index.html
+Files: /usr/share/doc/python3-aiohappyeyeballs/html/*.html
diff --git a/debian/python-aiohappyeyeballs-doc.docs b/debian/python-aiohappyeyeballs-doc.docs
new file mode 100644
index 0000000..4ecc793
--- /dev/null
+++ b/debian/python-aiohappyeyeballs-doc.docs
@@ -0,0 +1 @@
+docs/_build/html
diff --git a/debian/python-aiohappyeyeballs-doc.links b/debian/python-aiohappyeyeballs-doc.links
new file mode 100644
index 0000000..51d82c2
--- /dev/null
+++ b/debian/python-aiohappyeyeballs-doc.links
@@ -0,0 +1 @@
+/usr/share/doc/python3-aiohappyeyeballs/html /usr/share/doc/python-aiohappyeyeballs-doc/html
diff --git a/debian/rules b/debian/rules
index 2d33f6a..f1c671b 100755
--- a/debian/rules
+++ b/debian/rules
@@ -1,4 +1,27 @@
#!/usr/bin/make -f
+export PYBUILD_NAME=aiohappyeyeballs
+export PYBUILD_TEST_ARGS=--no-cov -k "not test_single_addr_info_close_errors"
+
%:
- dh $@
+ dh $@ --buildsystem=pybuild
+
+override_dh_auto_clean:
+ dh_auto_clean
+ rm -rf aiohappyeyeballs.egg-info/
+ifeq (,$(findstring nodocs, $(DEB_BUILD_OPTIONS)))
+ make -C docs clean
+endif
+
+override_dh_installdocs:
+ dh_installdocs --doc-main-package=python3-aiohappyeyeballs -p python-aiohappyeyeballs-doc
+ dh_installdocs -p python3-aiohappyeyeballs
+
+
+
+override_dh_auto_build:
+ dh_auto_build
+ifeq (,$(findstring nodocs, $(DEB_BUILD_OPTIONS)))
+ # sphinx-apidoc -o docs/ aiohappyeyeballs
+ cd docs && LC_ALL=C.UTF-8 LANGUAGE=C.UTF-8 sphinx-build -b html -d _build/doctrees . _build/html
+endif
diff --git a/debian/tests/control b/debian/tests/control
new file mode 100644
index 0000000..3d32492
--- /dev/null
+++ b/debian/tests/control
@@ -0,0 +1,6 @@
+Tests: run-tests
+Depends:
+ python3-all,
+ python3-pytest,
+ python3-pytest-asyncio,
+ @,
diff --git a/debian/tests/run-tests b/debian/tests/run-tests
new file mode 100755
index 0000000..c9e3121
--- /dev/null
+++ b/debian/tests/run-tests
@@ -0,0 +1,6 @@
+#!/bin/sh
+set -e
+cp -r test* "$AUTOPKGTEST_TMP/" && cd "$AUTOPKGTEST_TMP"
+for py in $(py3versions -s); do
+ $py -Wd -m pytest -v --asyncio-mode=auto -x -k "not test_single_addr_info_close_errors" 2>&1
+done
diff --git a/debian/upstream/metadata b/debian/upstream/metadata
new file mode 100644
index 0000000..242bada
--- /dev/null
+++ b/debian/upstream/metadata
@@ -0,0 +1,4 @@
+Bug-Database: https://github.com/aio-libs/aiohappyeyeballs/issues
+Bug-Submit: https://github.com/aio-libs/aiohappyeyeballs/issues/new
+Repository: https://github.com/aio-libs/aiohappyeyeballs.git
+Repository-Browse: https://github.com/aio-libs/aiohappyeyeballs
diff --git a/debian/watch b/debian/watch
new file mode 100644
index 0000000..77d0da2
--- /dev/null
+++ b/debian/watch
@@ -0,0 +1,2 @@
+version=4
+https://github.com/aio-libs/aiohappyeyeballs/tags .*/v?(\d\S+)\.tar\.gz
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..6e1a37f
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,24 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = .
+BUILDDIR = _build
+
+.PHONY: help livehtml Makefile
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+# Build, watch and serve docs with live reload
+livehtml:
+ sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/changelog.md b/docs/changelog.md
new file mode 100644
index 0000000..c381d10
--- /dev/null
+++ b/docs/changelog.md
@@ -0,0 +1,5 @@
+(changelog)=
+
+```{include} ../CHANGELOG.md
+
+```
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..34992db
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,33 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# Project information
+project = "aiohappyeyeballs"
+copyright = "2023, J. Nick Koston"
+author = "J. Nick Koston"
+release = "2.4.4"
+
+# General configuration
+extensions = [
+ "myst_parser",
+]
+
+# The suffix of source filenames.
+source_suffix = [
+ ".rst",
+ ".md",
+]
+templates_path = [
+ "_templates",
+]
+exclude_patterns = [
+ "_build",
+ "Thumbs.db",
+ ".DS_Store",
+]
+
+# Options for HTML output
+html_theme = "furo"
+html_static_path = ["_static"]
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..06aeb01
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,5 @@
+(contributing)=
+
+```{include} ../CONTRIBUTING.md
+
+```
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..483283e
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,21 @@
+# Welcome to aiohappyeyeballs documentation!
+
+```{toctree}
+:caption: Installation & Usage
+:maxdepth: 2
+
+installation
+usage
+```
+
+```{toctree}
+:caption: Project Info
+:maxdepth: 2
+
+changelog
+contributing
+```
+
+```{include} ../README.md
+
+```
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..73b322e
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,11 @@
+(installation)=
+
+# Installation
+
+The package is published on [PyPI](https://pypi.org/project/aiohappyeyeballs/) and can be installed with `pip` (or any equivalent):
+
+```bash
+pip install aiohappyeyeballs
+```
+
+Next, see the {ref}`section about usage ` to see how to use it.
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..954237b
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..b79e1ec
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,28 @@
+(usage)=
+
+# Usage
+
+Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package.
+
+Start by importing it:
+
+```python
+import aiohappyeyeballs
+
+addr_infos = await loop.getaddrinfo("example.org", 80)
+
+socket = await aiohappyeyeballs.start_connection(addr_infos)
+socket = await aiohappyeyeballs.start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
+
+transport, protocol = await loop.create_connection(
+ MyProtocol, sock=socket, ...)
+
+# Remove the first address for each family from addr_info
+aiohappyeyeballs.pop_addr_infos_interleave(addr_info, 1)
+
+# Remove all matching address from addr_info
+aiohappyeyeballs.remove_addr_infos(addr_info, "dead::beef::")
+
+# Convert a local_addr to local_addr_infos
+local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
+```
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..11ae96b
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1348 @@
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+
+[[package]]
+name = "alabaster"
+version = "0.7.13"
+description = "A configurable sidebar-enabled Sphinx theme"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"},
+ {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"},
+]
+
+[[package]]
+name = "babel"
+version = "2.13.1"
+description = "Internationalization utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"},
+ {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"},
+]
+
+[package.dependencies]
+pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""}
+setuptools = {version = "*", markers = "python_version >= \"3.12\""}
+
+[package.extras]
+dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.12.2"
+description = "Screen-scraping library"
+optional = false
+python-versions = ">=3.6.0"
+files = [
+ {file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"},
+ {file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"},
+]
+
+[package.dependencies]
+soupsieve = ">1.2"
+
+[package.extras]
+html5lib = ["html5lib"]
+lxml = ["lxml"]
+
+[[package]]
+name = "certifi"
+version = "2023.11.17"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
+ {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
+]
+
+[[package]]
+name = "cffi"
+version = "1.16.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"},
+ {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"},
+ {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"},
+ {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"},
+ {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"},
+ {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"},
+ {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"},
+ {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"},
+ {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"},
+ {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"},
+ {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"},
+ {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"},
+ {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"},
+ {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"},
+ {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"},
+ {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"},
+ {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"},
+ {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"},
+ {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"},
+ {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"},
+ {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"},
+ {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"},
+ {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"},
+ {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"},
+ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"},
+ {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"},
+]
+
+[package.dependencies]
+pycparser = "*"
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.3.2"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"},
+ {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"},
+ {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"},
+ {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"},
+ {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"},
+ {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"},
+ {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"},
+ {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"},
+ {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"},
+ {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"},
+ {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"},
+ {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"},
+ {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"},
+ {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"},
+ {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"},
+ {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"},
+ {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"},
+ {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"},
+ {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"},
+ {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"},
+ {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"},
+ {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"},
+ {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"},
+ {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"},
+ {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"},
+ {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"},
+ {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"},
+ {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"},
+ {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"},
+ {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"},
+ {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"},
+ {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"},
+ {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"},
+ {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"},
+ {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"},
+ {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"},
+ {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"},
+ {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"},
+ {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"},
+ {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"},
+ {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"},
+ {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"},
+ {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"},
+ {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"},
+ {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"},
+ {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"},
+ {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"},
+ {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"},
+ {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"},
+ {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"},
+ {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"},
+ {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"},
+]
+
+[package.dependencies]
+tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "cryptography"
+version = "41.0.7"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"},
+ {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"},
+ {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"},
+ {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"},
+ {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"},
+ {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"},
+ {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"},
+ {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"},
+ {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"},
+ {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"},
+ {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"},
+]
+
+[package.dependencies]
+cffi = ">=1.12"
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]
+docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"]
+nox = ["nox"]
+pep8test = ["black", "check-sdist", "mypy", "ruff"]
+sdist = ["build"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "docutils"
+version = "0.20.1"
+description = "Docutils -- Python Documentation Utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"},
+ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"},
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.0"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
+ {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
+]
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "furo"
+version = "2023.9.10"
+description = "A clean customisable Sphinx documentation theme."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "furo-2023.9.10-py3-none-any.whl", hash = "sha256:513092538537dc5c596691da06e3c370714ec99bc438680edc1debffb73e5bfc"},
+ {file = "furo-2023.9.10.tar.gz", hash = "sha256:5707530a476d2a63b8cad83b4f961f3739a69f4b058bcf38a03a39fa537195b2"},
+]
+
+[package.dependencies]
+beautifulsoup4 = "*"
+pygments = ">=2.7"
+sphinx = ">=6.0,<8.0"
+sphinx-basic-ng = "*"
+
+[[package]]
+name = "idna"
+version = "3.6"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
+
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+description = "Getting image size from png/jpeg/jpeg2000/gif file"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "7.0.0"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "importlib_metadata-7.0.0-py3-none-any.whl", hash = "sha256:d97503976bb81f40a193d41ee6570868479c69d5068651eb039c40d850c59d67"},
+ {file = "importlib_metadata-7.0.0.tar.gz", hash = "sha256:7fc841f8b8332803464e5dc1c63a2e59121f46ca186c0e2e182e80bf8c1319f7"},
+]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+perf = ["ipython"]
+testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
+
+[[package]]
+name = "importlib-resources"
+version = "6.1.1"
+description = "Read resources from Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "importlib_resources-6.1.1-py3-none-any.whl", hash = "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6"},
+ {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"},
+]
+
+[package.dependencies]
+zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.3.0"
+description = "Utility functions for Python class constructs"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "jaraco.classes-3.3.0-py3-none-any.whl", hash = "sha256:10afa92b6743f25c0cf5f37c6bb6e18e2c5bb84a16527ccfc0040ea377e7aaeb"},
+ {file = "jaraco.classes-3.3.0.tar.gz", hash = "sha256:c063dd08e89217cee02c8d5e5ec560f2c8ce6cdc2fcdc2e68f7b2e5547ed3621"},
+]
+
+[package.dependencies]
+more-itertools = "*"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+
+[[package]]
+name = "jeepney"
+version = "0.8.0"
+description = "Low-level, pure Python DBus protocol wrapper."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755"},
+ {file = "jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806"},
+]
+
+[package.extras]
+test = ["async-timeout", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+trio = ["async_generator", "trio"]
+
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
+ {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "keyring"
+version = "24.3.0"
+description = "Store and access your passwords safely."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "keyring-24.3.0-py3-none-any.whl", hash = "sha256:4446d35d636e6a10b8bce7caa66913dd9eca5fd222ca03a3d42c38608ac30836"},
+ {file = "keyring-24.3.0.tar.gz", hash = "sha256:e730ecffd309658a08ee82535a3b5ec4b4c8669a9be11efb66249d8e0aeb9a25"},
+]
+
+[package.dependencies]
+importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
+importlib-resources = {version = "*", markers = "python_version < \"3.9\""}
+"jaraco.classes" = "*"
+jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
+pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
+SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
+
+[package.extras]
+completion = ["shtab (>=1.1.0)"]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+
+[[package]]
+name = "livereload"
+version = "2.6.3"
+description = "Python LiveReload is an awesome tool for web developers"
+optional = false
+python-versions = "*"
+files = [
+ {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"},
+ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
+]
+
+[package.dependencies]
+six = "*"
+tornado = {version = "*", markers = "python_version > \"2.7\""}
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"},
+ {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.3"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
+ {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
+ {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
+ {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
+ {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
+ {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
+ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
+]
+
+[[package]]
+name = "mdit-py-plugins"
+version = "0.4.0"
+description = "Collection of plugins for markdown-it-py"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "mdit_py_plugins-0.4.0-py3-none-any.whl", hash = "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9"},
+ {file = "mdit_py_plugins-0.4.0.tar.gz", hash = "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=1.0.0,<4.0.0"
+
+[package.extras]
+code-style = ["pre-commit"]
+rtd = ["myst-parser", "sphinx-book-theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "more-itertools"
+version = "10.1.0"
+description = "More routines for operating on iterables, beyond itertools"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "more-itertools-10.1.0.tar.gz", hash = "sha256:626c369fa0eb37bac0291bce8259b332fd59ac792fa5497b59837309cd5b114a"},
+ {file = "more_itertools-10.1.0-py3-none-any.whl", hash = "sha256:64e0735fcfdc6f3464ea133afe8ea4483b1c5fe3a3d69852e6503b43a0b222e6"},
+]
+
+[[package]]
+name = "myst-parser"
+version = "2.0.0"
+description = "An extended [CommonMark](https://spec.commonmark.org/) compliant parser,"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "myst_parser-2.0.0-py3-none-any.whl", hash = "sha256:7c36344ae39c8e740dad7fdabf5aa6fc4897a813083c6cc9990044eb93656b14"},
+ {file = "myst_parser-2.0.0.tar.gz", hash = "sha256:ea929a67a6a0b1683cdbe19b8d2e724cd7643f8aa3e7bb18dd65beac3483bead"},
+]
+
+[package.dependencies]
+docutils = ">=0.16,<0.21"
+jinja2 = "*"
+markdown-it-py = ">=3.0,<4.0"
+mdit-py-plugins = ">=0.4,<1.0"
+pyyaml = "*"
+sphinx = ">=6,<8"
+
+[package.extras]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+linkify = ["linkify-it-py (>=2.0,<3.0)"]
+rtd = ["ipython", "pydata-sphinx-theme (==v0.13.0rc4)", "sphinx-autodoc2 (>=0.4.2,<0.5.0)", "sphinx-book-theme (==1.0.0rc2)", "sphinx-copybutton", "sphinx-design2", "sphinx-pyscript", "sphinx-tippy (>=0.3.1)", "sphinx-togglebutton", "sphinxext-opengraph (>=0.8.2,<0.9.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"]
+testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=7,<8)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx-pytest"]
+testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4,<0.4.0)"]
+
+[[package]]
+name = "nh3"
+version = "0.2.15"
+description = "Python bindings to the ammonia HTML sanitization library."
+optional = false
+python-versions = "*"
+files = [
+ {file = "nh3-0.2.15-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9c0d415f6b7f2338f93035bba5c0d8c1b464e538bfbb1d598acd47d7969284f0"},
+ {file = "nh3-0.2.15-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6f42f99f0cf6312e470b6c09e04da31f9abaadcd3eb591d7d1a88ea931dca7f3"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac19c0d68cd42ecd7ead91a3a032fdfff23d29302dbb1311e641a130dfefba97"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0d77272ce6d34db6c87b4f894f037d55183d9518f948bba236fe81e2bb4e28"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8d595df02413aa38586c24811237e95937ef18304e108b7e92c890a06793e3bf"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86e447a63ca0b16318deb62498db4f76fc60699ce0a1231262880b38b6cff911"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3277481293b868b2715907310c7be0f1b9d10491d5adf9fce11756a97e97eddf"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60684857cfa8fdbb74daa867e5cad3f0c9789415aba660614fe16cd66cbb9ec7"},
+ {file = "nh3-0.2.15-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b803a5875e7234907f7d64777dfde2b93db992376f3d6d7af7f3bc347deb305"},
+ {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d02d0ff79dfd8208ed25a39c12cbda092388fff7f1662466e27d97ad011b770"},
+ {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f3b53ba93bb7725acab1e030bc2ecd012a817040fd7851b332f86e2f9bb98dc6"},
+ {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1e97221cedaf15a54f5243f2c5894bb12ca951ae4ddfd02a9d4ea9df9e1a29d"},
+ {file = "nh3-0.2.15-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5167a6403d19c515217b6bcaaa9be420974a6ac30e0da9e84d4fc67a5d474c5"},
+ {file = "nh3-0.2.15-cp37-abi3-win32.whl", hash = "sha256:427fecbb1031db085eaac9931362adf4a796428ef0163070c484b5a768e71601"},
+ {file = "nh3-0.2.15-cp37-abi3-win_amd64.whl", hash = "sha256:bc2d086fb540d0fa52ce35afaded4ea526b8fc4d3339f783db55c95de40ef02e"},
+ {file = "nh3-0.2.15.tar.gz", hash = "sha256:d1e30ff2d8d58fb2a14961f7aac1bbb1c51f9bdd7da727be35c63826060b0bf3"},
+]
+
+[[package]]
+name = "packaging"
+version = "23.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+]
+
+[[package]]
+name = "pkginfo"
+version = "1.9.6"
+description = "Query metadata from sdists / bdists / installed packages."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pkginfo-1.9.6-py3-none-any.whl", hash = "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546"},
+ {file = "pkginfo-1.9.6.tar.gz", hash = "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046"},
+]
+
+[package.extras]
+testing = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "pluggy"
+version = "1.3.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
+ {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pycparser"
+version = "2.21"
+description = "C parser in Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
+ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
+]
+
+[[package]]
+name = "pygments"
+version = "2.17.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
+ {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pytest"
+version = "7.4.3"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
+ {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=0.12,<2.0"
+tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.23.2"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-asyncio-0.23.2.tar.gz", hash = "sha256:c16052382554c7b22d48782ab3438d5b10f8cf7a4bdcae7f0f67f097d95beecc"},
+ {file = "pytest_asyncio-0.23.2-py3-none-any.whl", hash = "sha256:ea9021364e32d58f0be43b91c6233fb8d2224ccef2398d6837559e587682808f"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0"
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-cov"
+version = "3.0.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
+ {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
+]
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
+
+[[package]]
+name = "pytz"
+version = "2023.3.post1"
+description = "World timezone definitions, modern and historical"
+optional = false
+python-versions = "*"
+files = [
+ {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"},
+ {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"},
+]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.2"
+description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "pywin32-ctypes-0.2.2.tar.gz", hash = "sha256:3426e063bdd5fd4df74a14fa3cf80a0b42845a87e1d1e81f6549f9daec593a60"},
+ {file = "pywin32_ctypes-0.2.2-py3-none-any.whl", hash = "sha256:bf490a1a709baf35d688fe0ecf980ed4de11d2b3e37b51e5442587a75d9957e7"},
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "readme-renderer"
+version = "42.0"
+description = "readme_renderer is a library for rendering readme descriptions for Warehouse"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "readme_renderer-42.0-py3-none-any.whl", hash = "sha256:13d039515c1f24de668e2c93f2e877b9dbe6c6c32328b90a40a49d8b2b85f36d"},
+ {file = "readme_renderer-42.0.tar.gz", hash = "sha256:2d55489f83be4992fe4454939d1a051c33edbab778e82761d060c9fc6b308cd1"},
+]
+
+[package.dependencies]
+docutils = ">=0.13.1"
+nh3 = ">=0.2.14"
+Pygments = ">=2.5.1"
+
+[package.extras]
+md = ["cmarkgfm (>=0.8.0)"]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+description = "A utility belt for advanced users of python-requests"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"},
+ {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"},
+]
+
+[package.dependencies]
+requests = ">=2.0.1,<3.0.0"
+
+[[package]]
+name = "rfc3986"
+version = "2.0.0"
+description = "Validating URI References per RFC 3986"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"},
+ {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"},
+]
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "rich"
+version = "13.7.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "rich-13.7.0-py3-none-any.whl", hash = "sha256:6da14c108c4866ee9520bbffa71f6fe3962e193b7da68720583850cd4548e235"},
+ {file = "rich-13.7.0.tar.gz", hash = "sha256:5cb5123b5cf9ee70584244246816e9114227e0b98ad9176eede6ad54bf5403fa"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.9\""}
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "secretstorage"
+version = "3.3.3"
+description = "Python bindings to FreeDesktop.org Secret Service API"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"},
+ {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"},
+]
+
+[package.dependencies]
+cryptography = ">=2.0"
+jeepney = ">=0.6"
+
+[[package]]
+name = "setuptools"
+version = "69.0.2"
+description = "Easily download, build, install, upgrade, and uninstall Python packages"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"},
+ {file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
+testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
+testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "snowballstemmer"
+version = "2.2.0"
+description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
+optional = false
+python-versions = "*"
+files = [
+ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.5"
+description = "A modern CSS selector implementation for Beautiful Soup."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
+ {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
+]
+
+[[package]]
+name = "sphinx"
+version = "7.1.2"
+description = "Python documentation generator"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "sphinx-7.1.2-py3-none-any.whl", hash = "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe"},
+ {file = "sphinx-7.1.2.tar.gz", hash = "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f"},
+]
+
+[package.dependencies]
+alabaster = ">=0.7,<0.8"
+babel = ">=2.9"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+docutils = ">=0.18.1,<0.21"
+imagesize = ">=1.3"
+importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""}
+Jinja2 = ">=3.0"
+packaging = ">=21.0"
+Pygments = ">=2.13"
+requests = ">=2.25.0"
+snowballstemmer = ">=2.0"
+sphinxcontrib-applehelp = "*"
+sphinxcontrib-devhelp = "*"
+sphinxcontrib-htmlhelp = ">=2.0.0"
+sphinxcontrib-jsmath = "*"
+sphinxcontrib-qthelp = "*"
+sphinxcontrib-serializinghtml = ">=1.1.5"
+
+[package.extras]
+docs = ["sphinxcontrib-websupport"]
+lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"]
+test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"]
+
+[[package]]
+name = "sphinx-autobuild"
+version = "2021.3.14"
+description = "Rebuild Sphinx documentation on changes, with live-reload in the browser."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"},
+ {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"},
+]
+
+[package.dependencies]
+colorama = "*"
+livereload = "*"
+sphinx = "*"
+
+[package.extras]
+test = ["pytest", "pytest-cov"]
+
+[[package]]
+name = "sphinx-basic-ng"
+version = "1.0.0b2"
+description = "A modern skeleton for Sphinx themes."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
+ {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
+]
+
+[package.dependencies]
+sphinx = ">=4.0"
+
+[package.extras]
+docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "1.0.4"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"},
+ {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "1.0.2"
+description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"},
+ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.0.1"
+description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"},
+ {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["html5lib", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+description = "A sphinx extension which renders display math in HTML via JavaScript"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+ {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
+
+[package.extras]
+test = ["flake8", "mypy", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "1.0.3"
+description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"},
+ {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "1.1.5"
+description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"},
+ {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+test = ["pytest"]
+
+[[package]]
+name = "tomli"
+version = "2.0.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
+ {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
+]
+
+[[package]]
+name = "tornado"
+version = "6.4"
+description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
+optional = false
+python-versions = ">= 3.8"
+files = [
+ {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"},
+ {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"},
+ {file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"},
+ {file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"},
+ {file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"},
+ {file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"},
+ {file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"},
+ {file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"},
+ {file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"},
+ {file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"},
+ {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"},
+]
+
+[[package]]
+name = "twine"
+version = "4.0.2"
+description = "Collection of utilities for publishing packages on PyPI"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "twine-4.0.2-py3-none-any.whl", hash = "sha256:929bc3c280033347a00f847236564d1c52a3e61b1ac2516c97c48f3ceab756d8"},
+ {file = "twine-4.0.2.tar.gz", hash = "sha256:9e102ef5fdd5a20661eb88fad46338806c3bd32cf1db729603fe3697b1bc83c8"},
+]
+
+[package.dependencies]
+importlib-metadata = ">=3.6"
+keyring = ">=15.1"
+pkginfo = ">=1.8.1"
+readme-renderer = ">=35.0"
+requests = ">=2.20"
+requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
+rfc3986 = ">=1.4.0"
+rich = ">=12.0.0"
+urllib3 = ">=1.26.0"
+
+[[package]]
+name = "typing-extensions"
+version = "4.9.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"},
+ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.1.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"},
+ {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "zipp"
+version = "3.17.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
+ {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+
+[metadata]
+lock-version = "2.0"
+python-versions = ">=3.8"
+content-hash = "239acad9c5b4ac6e5fec2151595577aead61a68c013090eeee37f7b99c5e41e3"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..18b6cfa
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,159 @@
+[tool.poetry]
+name = "aiohappyeyeballs"
+version = "2.4.4"
+description = "Happy Eyeballs for asyncio"
+authors = ["J. Nick Koston "]
+license = "PSF-2.0"
+readme = "README.md"
+repository = "https://github.com/aio-libs/aiohappyeyeballs"
+documentation = "https://aiohappyeyeballs.readthedocs.io"
+classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers",
+ "Natural Language :: English",
+ "Operating System :: OS Independent",
+ "Topic :: Software Development :: Libraries",
+ "Programming Language :: Python :: 3.8",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "License :: OSI Approved :: Python Software Foundation License"
+]
+packages = [
+ { include = "aiohappyeyeballs", from = "src" },
+ { include = "tests", format = "sdist" },
+]
+
+[tool.poetry.urls]
+"Bug Tracker" = "https://github.com/aio-libs/aiohappyeyeballs/issues"
+"Changelog" = "https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md"
+
+[tool.poetry.dependencies]
+python = ">=3.8"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^7.0"
+pytest-cov = "^3.0"
+pytest-asyncio = "^0.23.2"
+
+[tool.poetry.group.docs]
+optional = true
+
+[tool.poetry.group.docs.dependencies]
+myst-parser = ">=0.16"
+sphinx = ">=4.0"
+furo = ">=2023.5.20"
+sphinx-autobuild = ">=2021.3.14"
+
+
+[tool.poetry.group.test_build.dependencies]
+twine = "^4.0.2"
+
+[tool.semantic_release]
+version_toml = ["pyproject.toml:tool.poetry.version"]
+version_variables = [
+ "src/aiohappyeyeballs/__init__.py:__version__",
+ "docs/conf.py:release",
+]
+build_command = "pip install poetry && poetry build"
+
+[tool.semantic_release.changelog]
+exclude_commit_patterns = [
+ "chore*",
+ "ci*",
+]
+
+[tool.semantic_release.changelog.environment]
+keep_trailing_newline = true
+
+[tool.semantic_release.branches.main]
+match = "main"
+
+[tool.semantic_release.branches.noop]
+match = "(?!main$)"
+prerelease = true
+
+[tool.pytest.ini_options]
+addopts = "-v -Wdefault --cov=aiohappyeyeballs --cov-report=term-missing:skip-covered"
+pythonpath = ["src"]
+
+[tool.coverage.run]
+branch = true
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "@overload",
+ "if TYPE_CHECKING",
+ "raise NotImplementedError",
+ 'if __name__ == "__main__":',
+]
+
+[tool.ruff]
+target-version = "py38"
+line-length = 88
+ignore = [
+ "D203", # 1 blank line required before class docstring
+ "D212", # Multi-line docstring summary should start at the first line
+ "D100", # Missing docstring in public module
+ "D104", # Missing docstring in public package
+ "D107", # Missing docstring in `__init__`
+ "D401", # First line of docstring should be in imperative mood
+]
+select = [
+ "B", # flake8-bugbear
+ "D", # flake8-docstrings
+ "C4", # flake8-comprehensions
+ "S", # flake8-bandit
+ "F", # pyflake
+ "E", # pycodestyle
+ "W", # pycodestyle
+ "UP", # pyupgrade
+ "I", # isort
+ "RUF", # ruff specific
+]
+
+[tool.ruff.per-file-ignores]
+"tests/**/*" = [
+ "D100",
+ "D101",
+ "D102",
+ "D103",
+ "D104",
+ "S101",
+]
+"setup.py" = ["D100"]
+"conftest.py" = ["D100"]
+"docs/conf.py" = ["D100"]
+
+[tool.ruff.isort]
+known-first-party = ["aiohappyeyeballs", "tests"]
+
+[tool.mypy]
+check_untyped_defs = true
+disallow_any_generics = true
+disallow_incomplete_defs = true
+disallow_untyped_defs = true
+mypy_path = "src/"
+no_implicit_optional = true
+show_error_codes = true
+warn_unreachable = true
+warn_unused_ignores = true
+exclude = [
+ 'docs/.*',
+ 'setup.py',
+]
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+allow_untyped_defs = true
+
+[[tool.mypy.overrides]]
+module = "docs.*"
+ignore_errors = true
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..0200e15
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,3 @@
+{
+ "extends": ["github>browniebroke/renovate-configs:python"]
+}
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..889a1ed
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,9 @@
+#!/usr/bin/env python
+
+# This is a shim to allow GitHub to detect the package, build is done with poetry
+# Taken from https://github.com/Textualize/rich
+
+import setuptools
+
+if __name__ == "__main__":
+ setuptools.setup(name="aiohappyeyeballs")
diff --git a/src/aiohappyeyeballs/__init__.py b/src/aiohappyeyeballs/__init__.py
new file mode 100644
index 0000000..5520f4c
--- /dev/null
+++ b/src/aiohappyeyeballs/__init__.py
@@ -0,0 +1,13 @@
+__version__ = "2.4.4"
+
+from .impl import start_connection
+from .types import AddrInfoType
+from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos
+
+__all__ = (
+ "AddrInfoType",
+ "addr_to_addr_infos",
+ "pop_addr_infos_interleave",
+ "remove_addr_infos",
+ "start_connection",
+)
diff --git a/src/aiohappyeyeballs/_staggered.py b/src/aiohappyeyeballs/_staggered.py
new file mode 100644
index 0000000..dd0efb9
--- /dev/null
+++ b/src/aiohappyeyeballs/_staggered.py
@@ -0,0 +1,202 @@
+import asyncio
+import contextlib
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Awaitable,
+ Callable,
+ Iterable,
+ List,
+ Optional,
+ Set,
+ Tuple,
+ TypeVar,
+ Union,
+)
+
+_T = TypeVar("_T")
+
+
+def _set_result(wait_next: "asyncio.Future[None]") -> None:
+ """Set the result of a future if it is not already done."""
+ if not wait_next.done():
+ wait_next.set_result(None)
+
+
+async def _wait_one(
+ futures: "Iterable[asyncio.Future[Any]]",
+ loop: asyncio.AbstractEventLoop,
+) -> _T:
+ """Wait for the first future to complete."""
+ wait_next = loop.create_future()
+
+ def _on_completion(fut: "asyncio.Future[Any]") -> None:
+ if not wait_next.done():
+ wait_next.set_result(fut)
+
+ for f in futures:
+ f.add_done_callback(_on_completion)
+
+ try:
+ return await wait_next
+ finally:
+ for f in futures:
+ f.remove_done_callback(_on_completion)
+
+
+async def staggered_race(
+ coro_fns: Iterable[Callable[[], Awaitable[_T]]],
+ delay: Optional[float],
+ *,
+ loop: Optional[asyncio.AbstractEventLoop] = None,
+) -> Tuple[Optional[_T], Optional[int], List[Optional[BaseException]]]:
+ """
+ Run coroutines with staggered start times and take the first to finish.
+
+ This method takes an iterable of coroutine functions. The first one is
+ started immediately. From then on, whenever the immediately preceding one
+ fails (raises an exception), or when *delay* seconds has passed, the next
+ coroutine is started. This continues until one of the coroutines complete
+ successfully, in which case all others are cancelled, or until all
+ coroutines fail.
+
+ The coroutines provided should be well-behaved in the following way:
+
+ * They should only ``return`` if completed successfully.
+
+ * They should always raise an exception if they did not complete
+ successfully. In particular, if they handle cancellation, they should
+ probably reraise, like this::
+
+ try:
+ # do work
+ except asyncio.CancelledError:
+ # undo partially completed work
+ raise
+
+ Args:
+ ----
+ coro_fns: an iterable of coroutine functions, i.e. callables that
+ return a coroutine object when called. Use ``functools.partial`` or
+ lambdas to pass arguments.
+
+ delay: amount of time, in seconds, between starting coroutines. If
+ ``None``, the coroutines will run sequentially.
+
+ loop: the event loop to use. If ``None``, the running loop is used.
+
+ Returns:
+ -------
+ tuple *(winner_result, winner_index, exceptions)* where
+
+ - *winner_result*: the result of the winning coroutine, or ``None``
+ if no coroutines won.
+
+ - *winner_index*: the index of the winning coroutine in
+ ``coro_fns``, or ``None`` if no coroutines won. If the winning
+ coroutine may return None on success, *winner_index* can be used
+ to definitively determine whether any coroutine won.
+
+ - *exceptions*: list of exceptions returned by the coroutines.
+ ``len(exceptions)`` is equal to the number of coroutines actually
+ started, and the order is the same as in ``coro_fns``. The winning
+ coroutine's entry is ``None``.
+
+ """
+ loop = loop or asyncio.get_running_loop()
+ exceptions: List[Optional[BaseException]] = []
+ tasks: Set[asyncio.Task[Optional[Tuple[_T, int]]]] = set()
+
+ async def run_one_coro(
+ coro_fn: Callable[[], Awaitable[_T]],
+ this_index: int,
+ start_next: "asyncio.Future[None]",
+ ) -> Optional[Tuple[_T, int]]:
+ """
+ Run a single coroutine.
+
+ If the coroutine fails, set the exception in the exceptions list and
+ start the next coroutine by setting the result of the start_next.
+
+ If the coroutine succeeds, return the result and the index of the
+ coroutine in the coro_fns list.
+
+ If SystemExit or KeyboardInterrupt is raised, re-raise it.
+ """
+ try:
+ result = await coro_fn()
+ except (SystemExit, KeyboardInterrupt):
+ raise
+ except BaseException as e:
+ exceptions[this_index] = e
+ _set_result(start_next) # Kickstart the next coroutine
+ return None
+
+ return result, this_index
+
+ start_next_timer: Optional[asyncio.TimerHandle] = None
+ start_next: Optional[asyncio.Future[None]]
+ task: asyncio.Task[Optional[Tuple[_T, int]]]
+ done: Union[asyncio.Future[None], asyncio.Task[Optional[Tuple[_T, int]]]]
+ coro_iter = iter(coro_fns)
+ this_index = -1
+ try:
+ while True:
+ if coro_fn := next(coro_iter, None):
+ this_index += 1
+ exceptions.append(None)
+ start_next = loop.create_future()
+ task = loop.create_task(run_one_coro(coro_fn, this_index, start_next))
+ tasks.add(task)
+ start_next_timer = (
+ loop.call_later(delay, _set_result, start_next) if delay else None
+ )
+ elif not tasks:
+ # We exhausted the coro_fns list and no tasks are running
+ # so we have no winner and all coroutines failed.
+ break
+
+ while tasks:
+ done = await _wait_one(
+ [*tasks, start_next] if start_next else tasks, loop
+ )
+ if done is start_next:
+ # The current task has failed or the timer has expired
+ # so we need to start the next task.
+ start_next = None
+ if start_next_timer:
+ start_next_timer.cancel()
+ start_next_timer = None
+
+ # Break out of the task waiting loop to start the next
+ # task.
+ break
+
+ if TYPE_CHECKING:
+ assert isinstance(done, asyncio.Task)
+
+ tasks.remove(done)
+ if winner := done.result():
+ return *winner, exceptions
+ finally:
+ # We either have:
+ # - a winner
+ # - all tasks failed
+ # - a KeyboardInterrupt or SystemExit.
+
+ #
+ # If the timer is still running, cancel it.
+ #
+ if start_next_timer:
+ start_next_timer.cancel()
+
+ #
+ # If there are any tasks left, cancel them and than
+ # wait them so they fill the exceptions list.
+ #
+ for task in tasks:
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
+ return None, None, exceptions
diff --git a/src/aiohappyeyeballs/impl.py b/src/aiohappyeyeballs/impl.py
new file mode 100644
index 0000000..3fc743b
--- /dev/null
+++ b/src/aiohappyeyeballs/impl.py
@@ -0,0 +1,221 @@
+"""Base implementation."""
+
+import asyncio
+import collections
+import functools
+import itertools
+import socket
+import sys
+from typing import List, Optional, Sequence, Union
+
+from . import _staggered
+from .types import AddrInfoType
+
+if sys.version_info < (3, 8, 2): # noqa: UP036
+ # asyncio.staggered is broken in Python 3.8.0 and 3.8.1
+ # so it must be patched:
+ # https://github.com/aio-libs/aiohttp/issues/8556
+ # https://bugs.python.org/issue39129
+ # https://github.com/python/cpython/pull/17693
+ import asyncio.futures
+
+ asyncio.futures.TimeoutError = asyncio.TimeoutError # type: ignore[attr-defined]
+
+
+async def start_connection(
+ addr_infos: Sequence[AddrInfoType],
+ *,
+ local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
+ happy_eyeballs_delay: Optional[float] = None,
+ interleave: Optional[int] = None,
+ loop: Optional[asyncio.AbstractEventLoop] = None,
+) -> socket.socket:
+ """
+ Connect to a TCP server.
+
+ Create a socket connection to a specified destination. The
+ destination is specified as a list of AddrInfoType tuples as
+ returned from getaddrinfo().
+
+ The arguments are, in order:
+
+ * ``family``: the address family, e.g. ``socket.AF_INET`` or
+ ``socket.AF_INET6``.
+ * ``type``: the socket type, e.g. ``socket.SOCK_STREAM`` or
+ ``socket.SOCK_DGRAM``.
+ * ``proto``: the protocol, e.g. ``socket.IPPROTO_TCP`` or
+ ``socket.IPPROTO_UDP``.
+ * ``canonname``: the canonical name of the address, e.g.
+ ``"www.python.org"``.
+ * ``sockaddr``: the socket address
+
+ This method is a coroutine which will try to establish the connection
+ in the background. When successful, the coroutine returns a
+ socket.
+
+ The expected use case is to use this method in conjunction with
+ loop.create_connection() to establish a connection to a server::
+
+ socket = await start_connection(addr_infos)
+ transport, protocol = await loop.create_connection(
+ MyProtocol, sock=socket, ...)
+ """
+ if not (current_loop := loop):
+ current_loop = asyncio.get_running_loop()
+
+ single_addr_info = len(addr_infos) == 1
+
+ if happy_eyeballs_delay is not None and interleave is None:
+ # If using happy eyeballs, default to interleave addresses by family
+ interleave = 1
+
+ if interleave and not single_addr_info:
+ addr_infos = _interleave_addrinfos(addr_infos, interleave)
+
+ sock: Optional[socket.socket] = None
+ # uvloop can raise RuntimeError instead of OSError
+ exceptions: List[List[Union[OSError, RuntimeError]]] = []
+ if happy_eyeballs_delay is None or single_addr_info:
+ # not using happy eyeballs
+ for addrinfo in addr_infos:
+ try:
+ sock = await _connect_sock(
+ current_loop, exceptions, addrinfo, local_addr_infos
+ )
+ break
+ except (RuntimeError, OSError):
+ continue
+ else: # using happy eyeballs
+ sock, _, _ = await _staggered.staggered_race(
+ (
+ functools.partial(
+ _connect_sock, current_loop, exceptions, addrinfo, local_addr_infos
+ )
+ for addrinfo in addr_infos
+ ),
+ happy_eyeballs_delay,
+ )
+
+ if sock is None:
+ all_exceptions = [exc for sub in exceptions for exc in sub]
+ try:
+ first_exception = all_exceptions[0]
+ if len(all_exceptions) == 1:
+ raise first_exception
+ else:
+ # If they all have the same str(), raise one.
+ model = str(first_exception)
+ if all(str(exc) == model for exc in all_exceptions):
+ raise first_exception
+ # Raise a combined exception so the user can see all
+ # the various error messages.
+ msg = "Multiple exceptions: {}".format(
+ ", ".join(str(exc) for exc in all_exceptions)
+ )
+ # If the errno is the same for all exceptions, raise
+ # an OSError with that errno.
+ if isinstance(first_exception, OSError):
+ first_errno = first_exception.errno
+ if all(
+ isinstance(exc, OSError) and exc.errno == first_errno
+ for exc in all_exceptions
+ ):
+ raise OSError(first_errno, msg)
+ elif isinstance(first_exception, RuntimeError) and all(
+ isinstance(exc, RuntimeError) for exc in all_exceptions
+ ):
+ raise RuntimeError(msg)
+ # We have a mix of OSError and RuntimeError
+ # so we have to pick which one to raise.
+ # and we raise OSError for compatibility
+ raise OSError(msg)
+ finally:
+ all_exceptions = None # type: ignore[assignment]
+ exceptions = None # type: ignore[assignment]
+
+ return sock
+
+
+async def _connect_sock(
+ loop: asyncio.AbstractEventLoop,
+ exceptions: List[List[Union[OSError, RuntimeError]]],
+ addr_info: AddrInfoType,
+ local_addr_infos: Optional[Sequence[AddrInfoType]] = None,
+) -> socket.socket:
+ """Create, bind and connect one socket."""
+ my_exceptions: List[Union[OSError, RuntimeError]] = []
+ exceptions.append(my_exceptions)
+ family, type_, proto, _, address = addr_info
+ sock = None
+ try:
+ sock = socket.socket(family=family, type=type_, proto=proto)
+ sock.setblocking(False)
+ if local_addr_infos is not None:
+ for lfamily, _, _, _, laddr in local_addr_infos:
+ # skip local addresses of different family
+ if lfamily != family:
+ continue
+ try:
+ sock.bind(laddr)
+ break
+ except OSError as exc:
+ msg = (
+ f"error while attempting to bind on "
+ f"address {laddr!r}: "
+ f"{exc.strerror.lower()}"
+ )
+ exc = OSError(exc.errno, msg)
+ my_exceptions.append(exc)
+ else: # all bind attempts failed
+ if my_exceptions:
+ raise my_exceptions.pop()
+ else:
+ raise OSError(f"no matching local address with {family=} found")
+ await loop.sock_connect(sock, address)
+ return sock
+ except (RuntimeError, OSError) as exc:
+ my_exceptions.append(exc)
+ if sock is not None:
+ try:
+ sock.close()
+ except OSError as e:
+ my_exceptions.append(e)
+ raise
+ raise
+ except:
+ if sock is not None:
+ try:
+ sock.close()
+ except OSError as e:
+ my_exceptions.append(e)
+ raise
+ raise
+ finally:
+ exceptions = my_exceptions = None # type: ignore[assignment]
+
+
+def _interleave_addrinfos(
+ addrinfos: Sequence[AddrInfoType], first_address_family_count: int = 1
+) -> List[AddrInfoType]:
+ """Interleave list of addrinfo tuples by family."""
+ # Group addresses by family
+ addrinfos_by_family: collections.OrderedDict[int, List[AddrInfoType]] = (
+ collections.OrderedDict()
+ )
+ for addr in addrinfos:
+ family = addr[0]
+ if family not in addrinfos_by_family:
+ addrinfos_by_family[family] = []
+ addrinfos_by_family[family].append(addr)
+ addrinfos_lists = list(addrinfos_by_family.values())
+
+ reordered: List[AddrInfoType] = []
+ if first_address_family_count > 1:
+ reordered.extend(addrinfos_lists[0][: first_address_family_count - 1])
+ del addrinfos_lists[0][: first_address_family_count - 1]
+ reordered.extend(
+ a
+ for a in itertools.chain.from_iterable(itertools.zip_longest(*addrinfos_lists))
+ if a is not None
+ )
+ return reordered
diff --git a/src/aiohappyeyeballs/py.typed b/src/aiohappyeyeballs/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/aiohappyeyeballs/types.py b/src/aiohappyeyeballs/types.py
new file mode 100644
index 0000000..01d79a2
--- /dev/null
+++ b/src/aiohappyeyeballs/types.py
@@ -0,0 +1,12 @@
+"""Types for aiohappyeyeballs."""
+
+import socket
+from typing import Tuple, Union
+
+AddrInfoType = Tuple[
+ Union[int, socket.AddressFamily],
+ Union[int, socket.SocketKind],
+ int,
+ str,
+ Tuple, # type: ignore[type-arg]
+]
diff --git a/src/aiohappyeyeballs/utils.py b/src/aiohappyeyeballs/utils.py
new file mode 100644
index 0000000..ea29adb
--- /dev/null
+++ b/src/aiohappyeyeballs/utils.py
@@ -0,0 +1,97 @@
+"""Utility functions for aiohappyeyeballs."""
+
+import ipaddress
+import socket
+from typing import Dict, List, Optional, Tuple, Union
+
+from .types import AddrInfoType
+
+
+def addr_to_addr_infos(
+ addr: Optional[
+ Union[Tuple[str, int, int, int], Tuple[str, int, int], Tuple[str, int]]
+ ],
+) -> Optional[List[AddrInfoType]]:
+ """Convert an address tuple to a list of addr_info tuples."""
+ if addr is None:
+ return None
+ host = addr[0]
+ port = addr[1]
+ is_ipv6 = ":" in host
+ if is_ipv6:
+ flowinfo = 0
+ scopeid = 0
+ addr_len = len(addr)
+ if addr_len >= 4:
+ scopeid = addr[3] # type: ignore[misc]
+ if addr_len >= 3:
+ flowinfo = addr[2] # type: ignore[misc]
+ addr = (host, port, flowinfo, scopeid)
+ family = socket.AF_INET6
+ else:
+ addr = (host, port)
+ family = socket.AF_INET
+ return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", addr)]
+
+
+def pop_addr_infos_interleave(
+ addr_infos: List[AddrInfoType], interleave: Optional[int] = None
+) -> None:
+ """
+ Pop addr_info from the list of addr_infos by family up to interleave times.
+
+ The interleave parameter is used to know how many addr_infos for
+ each family should be popped of the top of the list.
+ """
+ seen: Dict[int, int] = {}
+ if interleave is None:
+ interleave = 1
+ to_remove: List[AddrInfoType] = []
+ for addr_info in addr_infos:
+ family = addr_info[0]
+ if family not in seen:
+ seen[family] = 0
+ if seen[family] < interleave:
+ to_remove.append(addr_info)
+ seen[family] += 1
+ for addr_info in to_remove:
+ addr_infos.remove(addr_info)
+
+
+def _addr_tuple_to_ip_address(
+ addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
+) -> Union[
+ Tuple[ipaddress.IPv4Address, int], Tuple[ipaddress.IPv6Address, int, int, int]
+]:
+ """Convert an address tuple to an IPv4Address."""
+ return (ipaddress.ip_address(addr[0]), *addr[1:])
+
+
+def remove_addr_infos(
+ addr_infos: List[AddrInfoType],
+ addr: Union[Tuple[str, int], Tuple[str, int, int, int]],
+) -> None:
+ """
+ Remove an address from the list of addr_infos.
+
+ The addr value is typically the return value of
+ sock.getpeername().
+ """
+ bad_addrs_infos: List[AddrInfoType] = []
+ for addr_info in addr_infos:
+ if addr_info[-1] == addr:
+ bad_addrs_infos.append(addr_info)
+ if bad_addrs_infos:
+ for bad_addr_info in bad_addrs_infos:
+ addr_infos.remove(bad_addr_info)
+ return
+ # Slow path in case addr is formatted differently
+ match_addr = _addr_tuple_to_ip_address(addr)
+ for addr_info in addr_infos:
+ if match_addr == _addr_tuple_to_ip_address(addr_info[-1]):
+ bad_addrs_infos.append(addr_info)
+ if bad_addrs_infos:
+ for bad_addr_info in bad_addrs_infos:
+ addr_infos.remove(bad_addr_info)
+ return
+ raise ValueError(f"Address {addr} not found in addr_infos")
diff --git a/templates/CHANGELOG.md.j2 b/templates/CHANGELOG.md.j2
new file mode 100644
index 0000000..c1c91be
--- /dev/null
+++ b/templates/CHANGELOG.md.j2
@@ -0,0 +1,17 @@
+# Changelog
+
+{%- for version, release in context.history.released.items() %}
+
+## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }})
+
+{%- for category, commits in release["elements"].items() %}
+{# Category title: Breaking, Fix, Documentation #}
+### {{ category | capitalize }}
+{# List actual changes in the category #}
+{%- for commit in commits %}
+- {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }}))
+{%- endfor %}{# for commit #}
+
+{%- endfor %}{# for category, commits #}
+
+{%- endfor %}{# for version, release #}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..32a3c43
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,32 @@
+"""Configuration for the tests."""
+
+import asyncio
+import threading
+from typing import Generator
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def verify_threads_ended():
+ """Verify that the threads are not running after the test."""
+ threads_before = frozenset(threading.enumerate())
+ yield
+ threads = frozenset(threading.enumerate()) - threads_before
+ assert not threads
+
+
+@pytest.fixture(autouse=True)
+def verify_no_lingering_tasks(
+ event_loop: asyncio.AbstractEventLoop,
+) -> Generator[None, None, None]:
+ """Verify that all tasks are cleaned up."""
+ tasks_before = asyncio.all_tasks(event_loop)
+ yield
+
+ tasks = asyncio.all_tasks(event_loop) - tasks_before
+ for task in tasks:
+ pytest.fail(f"Task still running: {task!r}")
+ task.cancel()
+ if tasks:
+ event_loop.run_until_complete(asyncio.wait(tasks))
diff --git a/tests/test_impl.py b/tests/test_impl.py
new file mode 100644
index 0000000..aae89cb
--- /dev/null
+++ b/tests/test_impl.py
@@ -0,0 +1,1870 @@
+import asyncio
+import socket
+import sys
+from types import ModuleType
+from typing import Tuple
+from unittest import mock
+
+import pytest
+
+from aiohappyeyeballs import start_connection
+
+
+def mock_socket_module():
+ m_socket = mock.MagicMock(spec=socket)
+ for name in (
+ "AF_INET",
+ "AF_INET6",
+ "AF_UNSPEC",
+ "IPPROTO_TCP",
+ "IPPROTO_UDP",
+ "SOCK_STREAM",
+ "SOCK_DGRAM",
+ "SOL_SOCKET",
+ "SO_REUSEADDR",
+ "inet_pton",
+ ):
+ if hasattr(socket, name):
+ setattr(m_socket, name, getattr(socket, name))
+ else:
+ delattr(m_socket, name)
+
+ m_socket.socket = mock.MagicMock()
+ m_socket.socket.return_value = mock_nonblocking_socket()
+
+ return m_socket
+
+
+def mock_nonblocking_socket(
+ proto=socket.IPPROTO_TCP, type=socket.SOCK_STREAM, family=socket.AF_INET
+):
+ """Create a mock of a non-blocking socket."""
+ sock = mock.create_autospec(socket.socket, spec_set=True, instance=True)
+ sock.proto = proto
+ sock.type = type
+ sock.family = family
+ sock.gettimeout.return_value = 0.0
+ return sock
+
+
+def patch_socket(f):
+ return mock.patch("aiohappyeyeballs.impl.socket", new_callable=mock_socket_module)(
+ f
+ )
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_single_addr_info_errors(m_socket: ModuleType) -> None:
+ idx = -1
+ errors = ["err1", "err2"]
+
+ def _socket(*args, **kw):
+ nonlocal idx, errors
+ idx += 1
+ raise OSError(5, errors[idx])
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ )
+ ]
+ with pytest.raises(OSError, match=errors[0]):
+ await start_connection(addr_info)
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_single_addr_success(m_socket: ModuleType) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+
+ def _socket(*args, **kw):
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ )
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert await start_connection(addr_info) == mock_socket
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_single_addr_success_passing_loop(m_socket: ModuleType) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+
+ def _socket(*args, **kw):
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ )
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert (
+ await start_connection(addr_info, loop=asyncio.get_running_loop())
+ == mock_socket
+ )
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_multiple_addr_success_second_one(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ idx = -1
+ errors = ["err1", "err2"]
+
+ def _socket(*args, **kw):
+ nonlocal idx, errors
+ idx += 1
+ if idx == 1:
+ raise OSError(5, errors[idx])
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert await start_connection(addr_info) == mock_socket
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_multiple_addr_success_second_one_happy_eyeballs(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ idx = -1
+ errors = ["err1", "err2"]
+
+ def _socket(*args, **kw):
+ nonlocal idx, errors
+ idx += 1
+ if idx == 1:
+ raise OSError(5, errors[idx])
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket
+ )
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_multiple_addr_all_fail_happy_eyeballs(
+ m_socket: ModuleType,
+) -> None:
+ mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ idx = -1
+ errors = ["err1", "err2"]
+
+ def _socket(*args, **kw):
+ nonlocal idx, errors
+ idx += 1
+ raise OSError(5, errors[idx])
+
+ m_socket.socket = _socket # type: ignore
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ ),
+ ]
+ asyncio.get_running_loop()
+ with pytest.raises(OSError, match=errors[0]):
+ await start_connection(addr_info, happy_eyeballs_delay=0.3)
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv6_and_ipv4_happy_eyeballs_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+
+ def _socket(*args, **kw):
+ if kw["family"] == socket.AF_INET6:
+ raise OSError(5, "ipv6 fail")
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv4_addr_info]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket
+ )
+ assert mock_socket.family == socket.AF_INET
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv6_and_ipv4_happy_eyeballs_ipv4_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+
+ def _socket(*args, **kw):
+ if kw["family"] == socket.AF_INET:
+ raise OSError(5, "ipv4 fail")
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr: Tuple[str, int, int, int] = ("dead:beef::", 80, 0, 0)
+ ipv6_addr_info: Tuple[int, int, int, str, Tuple[str, int, int, int]] = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ipv6_addr,
+ )
+ ipv4_addr: Tuple[str, int] = ("107.6.106.83", 80)
+ ipv4_addr_info: Tuple[int, int, int, str, Tuple[str, int]] = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ipv4_addr,
+ )
+ addr_info = [ipv6_addr_info, ipv4_addr_info]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", return_value=None):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket
+ )
+ assert mock_socket.family == socket.AF_INET6
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv6_and_ipv4_happy_eyeballs_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket
+ )
+
+ # IPv6 addresses are tried first, but the first one fails so IPv4 wins
+ assert mock_socket.family == socket.AF_INET
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("107.6.106.83", 80)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_happy_eyeballs_interleave_2_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3, interleave=2)
+ == mock_socket
+ )
+
+ # IPv6 addresses are tried first, but the first one fails so second IPv6 wins
+ # because interleave is 2
+ assert mock_socket.family == socket.AF_INET6
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv6_only_happy_eyeballs_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket
+ )
+
+ # IPv6 address are tried first, but the first one fails so second IPv6 wins
+ assert mock_socket.family == socket.AF_INET6
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_eyeballs_interleave_2_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ )
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # IPv6 addresses are tried first, but the first one fails so second IPv6 wins
+ # because interleave is 2
+ assert mock_socket.family == socket.AF_INET6
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_both__eyeballs_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # IPv6 is tried first and fails, which means IPv4 is tried next and succeeds
+ assert mock_socket.family == socket.AF_INET
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("107.6.106.83", 80)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_bind_fails_eyeballs_first_ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ if kw["family"] == socket.AF_INET:
+ mock_socket.bind.side_effect = OSError(5, "bind fail")
+
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ OSError, match="ipv6 fail"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=1,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # We only tried IPv6 since bind to IPv4 failed
+ assert create_calls == [("dead:beef::", 80, 0, 0)]
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_bind_fails_eyeballs_interleave_first__ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ if kw["family"] == socket.AF_INET:
+ mock_socket.bind.side_effect = OSError(5, "bind fail")
+
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # IPv6 is tried first and fails, which means IPv4 is tried next but the laddr
+ # build fails so we move on to the next IPv6 and it succeeds
+ assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)]
+ assert mock_socket.family == socket.AF_INET6
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_socket_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ raise Exception("Something really went wrong")
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ Exception, match="Something really went wrong"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All binds failed
+ assert create_calls == []
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_socket_blocking_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ mock_socket.setblocking.side_effect = Exception("Something really went wrong")
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ Exception, match="Something really went wrong"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All binds failed
+ assert create_calls == []
+
+
+@pytest.mark.asyncio
+@patch_socket
+async def test_ipv64_laddr_eyeballs_ipv4_only_tried(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ )
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # Only IPv4 addresses are tried because local_addr_infos is IPv4
+ assert mock_socket.family == socket.AF_INET
+ assert create_calls == [("107.6.106.83", 80)]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_ipv64_laddr_bind_fails_all_eyeballs_interleave_first__ipv6_fails(
+ m_socket: ModuleType,
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ mock_socket.bind.side_effect = OSError(4, "bind fail")
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ if address[0] == "dead:beef::":
+ raise OSError(5, "ipv6 fail")
+
+ return None
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ OSError, match="Multiple exceptions"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All binds failed
+ assert create_calls == []
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_all_same_exception_and_same_errno(
+ m_socket: ModuleType,
+) -> None:
+ """Test that all exceptions are the same and have the same errno."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ raise OSError(5, "all fail")
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ OSError, match="all fail"
+ ) as exc_info:
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ assert exc_info.value.errno == 5
+
+ # All calls failed
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ("dead:aaaa::", 80, 0, 0),
+ ("107.6.106.83", 80),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_all_same_exception_and_with_different_errno(
+ m_socket: ModuleType,
+) -> None:
+ """Test no errno is set if all OSError have different errno."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ raise OSError(len(create_calls), "all fail")
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ OSError, match="all fail"
+ ) as exc_info:
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # No errno is set if they are all different
+ assert exc_info.value.errno is None
+
+ # All calls failed
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ("dead:aaaa::", 80, 0, 0),
+ ("107.6.106.83", 80),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_uvloop_runtime_error(
+ m_socket: ModuleType,
+) -> None:
+ """
+ Test RuntimeError is handled when connecting a socket with uvloop.
+
+ Connecting a socket can raise a RuntimeError, OSError or ValueError.
+
+ - OSError: If the address is invalid or the connection fails.
+ - ValueError: if a non-sock it passed (this should never happen).
+ https://github.com/python/cpython/blob/e44eebfc1eccdaaebc219accbfc705c9a9de068d/Lib/asyncio/selector_events.py#L271
+ - RuntimeError: If the file descriptor is already in use by a transport.
+
+ We should never get ValueError since we are using the correct types.
+
+ selector_events.py never seems to raise a RuntimeError, but it is possible
+ with uvloop. This test is to ensure that we handle it correctly.
+ """
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ raise RuntimeError("all fail")
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ RuntimeError, match="all fail"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All calls failed
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ("dead:aaaa::", 80, 0, 0),
+ ("107.6.106.83", 80),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_uvloop_different_runtime_error(
+ m_socket: ModuleType,
+) -> None:
+ """Test different RuntimeErrors are handled when connecting a socket with uvloop."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+ counter = 0
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ nonlocal counter
+ counter += 1
+ raise RuntimeError(counter)
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ RuntimeError, match="Multiple exceptions: 1, 2, 3"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All calls failed
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ("dead:aaaa::", 80, 0, 0),
+ ("107.6.106.83", 80),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_uvloop_mixing_os_and_runtime_error(
+ m_socket: ModuleType,
+) -> None:
+ """Test uvloop raising OSError and RuntimeError."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+ counter = 0
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ nonlocal counter
+ counter += 1
+ if counter == 1:
+ raise RuntimeError(counter)
+ raise OSError(counter, f"all fail {counter}")
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ OSError, match="Multiple exceptions: 1"
+ ):
+ assert (
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ == mock_socket
+ )
+
+ # All calls failed
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ("dead:aaaa::", 80, 0, 0),
+ ("107.6.106.83", 80),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+@pytest.mark.xfail(reason="raises RuntimeError: coroutine ignored GeneratorExit")
+async def test_handling_system_exit(
+ m_socket: ModuleType,
+) -> None:
+ """Test handling SystemExit."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ raise SystemExit
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ with pytest.raises(SystemExit), mock.patch.object(
+ loop, "sock_connect", _sock_connect
+ ):
+ await start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+
+ # Stopped after the first call
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ]
+
+
+@patch_socket
+@pytest.mark.asyncio
+async def test_cancellation_is_not_swallowed(
+ m_socket: ModuleType,
+) -> None:
+ """Test that cancellation is not swallowed."""
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ create_calls = []
+
+ def _socket(*args, **kw):
+ for attr in kw:
+ setattr(mock_socket, attr, kw[attr])
+ return mock_socket
+
+ async def _sock_connect(
+ sock: socket.socket, address: Tuple[str, int, int, int]
+ ) -> None:
+ create_calls.append(address)
+ await asyncio.sleep(1000)
+
+ m_socket.socket = _socket # type: ignore
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ local_addr_infos = [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("::1", 0, 0, 0),
+ ),
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("127.0.0.1", 0),
+ ),
+ ]
+ loop = asyncio.get_running_loop()
+ # We should get the same exception raised if they are all the same
+ with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises(
+ asyncio.CancelledError
+ ):
+ task = asyncio.create_task(
+ start_connection(
+ addr_info,
+ happy_eyeballs_delay=0.3,
+ interleave=2,
+ local_addr_infos=local_addr_infos,
+ )
+ )
+ await asyncio.sleep(0)
+ task.cancel()
+ await task
+
+ # After calls are cancelled now more are made
+ assert create_calls == [
+ ("dead:beef::", 80, 0, 0),
+ ]
+
+
+@pytest.mark.asyncio
+@pytest.mark.skipif(sys.version_info >= (3, 8, 2), reason="requires < python 3.8.2")
+def test_python_38_compat() -> None:
+ """Verify python < 3.8.2 compatibility."""
+ assert asyncio.futures.TimeoutError is asyncio.TimeoutError # type: ignore[attr-defined]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "connect_side_effect",
+ [
+ OSError("during connect"),
+ asyncio.CancelledError("during connect"),
+ ],
+)
+@patch_socket
+async def test_single_addr_info_close_errors(
+ m_socket: ModuleType, connect_side_effect: BaseException
+) -> None:
+ mock_socket = mock.MagicMock(
+ family=socket.AF_INET,
+ type=socket.SOCK_STREAM,
+ proto=socket.IPPROTO_TCP,
+ fileno=mock.MagicMock(return_value=1),
+ )
+ mock_socket.configure_mock(
+ **{
+ "connect.side_effect": connect_side_effect,
+ "close.side_effect": OSError("during close"),
+ }
+ )
+
+ def _socket(*args, **kw):
+ return mock_socket
+
+ m_socket.socket = _socket # type: ignore
+
+ addr_info = [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.82", 80),
+ )
+ ]
+ with pytest.raises(OSError, match="during close"):
+ await start_connection(addr_info)
diff --git a/tests/test_init.py b/tests/test_init.py
new file mode 100644
index 0000000..5152a8c
--- /dev/null
+++ b/tests/test_init.py
@@ -0,0 +1,5 @@
+from aiohappyeyeballs import start_connection
+
+
+def test_init():
+ assert start_connection is not None
diff --git a/tests/test_staggered.py b/tests/test_staggered.py
new file mode 100644
index 0000000..8c5f38b
--- /dev/null
+++ b/tests/test_staggered.py
@@ -0,0 +1,86 @@
+import asyncio
+import sys
+from functools import partial
+
+import pytest
+
+from aiohappyeyeballs._staggered import staggered_race
+
+
+@pytest.mark.asyncio
+async def test_one_winners():
+ """Test that there is only one winner when there is no await in the coro."""
+ winners = []
+
+ async def coro(idx):
+ winners.append(idx)
+ return idx
+
+ coros = [partial(coro, idx) for idx in range(4)]
+
+ winner, index, excs = await staggered_race(
+ coros,
+ delay=None,
+ )
+ assert len(winners) == 1
+ assert winners == [0]
+ assert winner == 0
+ assert index == 0
+ assert excs == [None]
+
+
+@pytest.mark.asyncio
+async def test_multiple_winners():
+ """Test multiple winners are handled correctly."""
+ loop = asyncio.get_running_loop()
+ winners = []
+ finish = loop.create_future()
+
+ async def coro(idx):
+ await finish
+ winners.append(idx)
+ return idx
+
+ coros = [partial(coro, idx) for idx in range(4)]
+
+ task = loop.create_task(staggered_race(coros, delay=0.00001))
+ await asyncio.sleep(0.1)
+ loop.call_soon(finish.set_result, None)
+ winner, index, excs = await task
+ assert len(winners) == 4
+ assert winners == [0, 1, 2, 3]
+ assert winner == 0
+ assert index == 0
+ assert excs == [None, None, None, None]
+
+
+@pytest.mark.skipif(sys.version_info < (3, 12), reason="requires python3.12 or higher")
+def test_multiple_winners_eager_task_factory():
+ """Test multiple winners are handled correctly."""
+ loop = asyncio.new_event_loop()
+ eager_task_factory = asyncio.create_eager_task_factory(asyncio.Task)
+ loop.set_task_factory(eager_task_factory)
+ asyncio.set_event_loop(None)
+
+ async def run():
+ winners = []
+ finish = loop.create_future()
+
+ async def coro(idx):
+ await finish
+ winners.append(idx)
+ return idx
+
+ coros = [partial(coro, idx) for idx in range(4)]
+
+ task = loop.create_task(staggered_race(coros, delay=0.00001))
+ await asyncio.sleep(0.1)
+ loop.call_soon(finish.set_result, None)
+ winner, index, excs = await task
+ assert len(winners) == 4
+ assert winners == [0, 1, 2, 3]
+ assert winner == 0
+ assert index == 0
+ assert excs == [None, None, None, None]
+
+ loop.run_until_complete(run())
diff --git a/tests/test_staggered_cpython.py b/tests/test_staggered_cpython.py
new file mode 100644
index 0000000..8607658
--- /dev/null
+++ b/tests/test_staggered_cpython.py
@@ -0,0 +1,146 @@
+"""
+Tests for staggered_race.
+
+These tests are copied from cpython to ensure our implementation is
+compatible with the one in cpython.
+"""
+
+import asyncio
+import unittest
+
+from aiohappyeyeballs._staggered import staggered_race
+
+
+def tearDownModule():
+ asyncio.set_event_loop_policy(None)
+
+
+class StaggeredTests(unittest.IsolatedAsyncioTestCase):
+ async def test_empty(self):
+ winner, index, excs = await staggered_race(
+ [],
+ delay=None,
+ )
+
+ self.assertIs(winner, None)
+ self.assertIs(index, None)
+ self.assertEqual(excs, [])
+
+ async def test_one_successful(self):
+ async def coro(index):
+ return f"Res: {index}"
+
+ winner, index, excs = await staggered_race(
+ [
+ lambda: coro(0),
+ lambda: coro(1),
+ ],
+ delay=None,
+ )
+
+ self.assertEqual(winner, "Res: 0")
+ self.assertEqual(index, 0)
+ self.assertEqual(excs, [None])
+
+ async def test_first_error_second_successful(self):
+ async def coro(index):
+ if index == 0:
+ raise ValueError(index)
+ return f"Res: {index}"
+
+ winner, index, excs = await staggered_race(
+ [
+ lambda: coro(0),
+ lambda: coro(1),
+ ],
+ delay=None,
+ )
+
+ self.assertEqual(winner, "Res: 1")
+ self.assertEqual(index, 1)
+ self.assertEqual(len(excs), 2)
+ self.assertIsInstance(excs[0], ValueError)
+ self.assertIs(excs[1], None)
+
+ async def test_first_timeout_second_successful(self):
+ async def coro(index):
+ if index == 0:
+ await asyncio.sleep(10) # much bigger than delay
+ return f"Res: {index}"
+
+ winner, index, excs = await staggered_race(
+ [
+ lambda: coro(0),
+ lambda: coro(1),
+ ],
+ delay=0.1,
+ )
+
+ self.assertEqual(winner, "Res: 1")
+ self.assertEqual(index, 1)
+ self.assertEqual(len(excs), 2)
+ self.assertIsInstance(excs[0], asyncio.CancelledError)
+ self.assertIs(excs[1], None)
+
+ async def test_none_successful(self):
+ async def coro(index):
+ raise ValueError(index)
+
+ for delay in [None, 0, 0.1, 1]:
+ with self.subTest(delay=delay):
+ winner, index, excs = await staggered_race(
+ [
+ lambda: coro(0),
+ lambda: coro(1),
+ ],
+ delay=delay,
+ )
+
+ self.assertIs(winner, None)
+ self.assertIs(index, None)
+ self.assertEqual(len(excs), 2)
+ self.assertIsInstance(excs[0], ValueError)
+ self.assertIsInstance(excs[1], ValueError)
+
+ async def test_long_delay_early_failure(self):
+ async def coro(index):
+ await asyncio.sleep(0) # Dummy coroutine for the 1 case
+ if index == 0:
+ await asyncio.sleep(0.1) # Dummy coroutine
+ raise ValueError(index)
+
+ return f"Res: {index}"
+
+ winner, index, excs = await staggered_race(
+ [
+ lambda: coro(0),
+ lambda: coro(1),
+ ],
+ delay=10,
+ )
+
+ self.assertEqual(winner, "Res: 1")
+ self.assertEqual(index, 1)
+ self.assertEqual(len(excs), 2)
+ self.assertIsInstance(excs[0], ValueError)
+ self.assertIsNone(excs[1])
+
+ def test_loop_argument(self):
+ loop = asyncio.new_event_loop()
+
+ async def coro():
+ self.assertEqual(loop, asyncio.get_running_loop())
+ return "coro"
+
+ async def main():
+ winner, index, excs = await staggered_race([coro], delay=0.1, loop=loop)
+
+ self.assertEqual(winner, "coro")
+ self.assertEqual(index, 0)
+
+ loop.run_until_complete(main())
+ loop.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_staggered_cpython_eager_task_factory.py b/tests/test_staggered_cpython_eager_task_factory.py
new file mode 100644
index 0000000..8d3cf28
--- /dev/null
+++ b/tests/test_staggered_cpython_eager_task_factory.py
@@ -0,0 +1,96 @@
+"""
+Tests staggered_race and eager_task_factory with asyncio.Task.
+
+These tests are copied from cpython to ensure our implementation is
+compatible with the one in cpython.
+"""
+
+import asyncio
+import sys
+import unittest
+
+from aiohappyeyeballs._staggered import staggered_race
+
+
+def tearDownModule():
+ asyncio.set_event_loop_policy(None)
+
+
+class EagerTaskFactoryLoopTests(unittest.TestCase):
+ def close_loop(self, loop):
+ loop.close()
+
+ def set_event_loop(self, loop, *, cleanup=True):
+ if loop is None:
+ raise AssertionError("loop is None")
+ # ensure that the event loop is passed explicitly in asyncio
+ asyncio.set_event_loop(None)
+ if cleanup:
+ self.addCleanup(self.close_loop, loop)
+
+ def tearDown(self):
+ asyncio.set_event_loop(None)
+ self.doCleanups()
+
+ def setUp(self):
+ if sys.version_info < (3, 12):
+ self.skipTest("eager_task_factory is only available in Python 3.12+")
+
+ super().setUp()
+ self.loop = asyncio.new_event_loop()
+ self.eager_task_factory = asyncio.create_eager_task_factory(asyncio.Task)
+ self.loop.set_task_factory(self.eager_task_factory)
+ self.set_event_loop(self.loop)
+
+ def test_staggered_race_with_eager_tasks(self):
+ # See https://github.com/python/cpython/issues/124309
+
+ async def fail():
+ await asyncio.sleep(0)
+ raise ValueError("no good")
+
+ async def run():
+ winner, index, excs = await staggered_race(
+ [
+ lambda: asyncio.sleep(2, result="sleep2"),
+ lambda: asyncio.sleep(1, result="sleep1"),
+ lambda: fail(),
+ ],
+ delay=0.25,
+ )
+ self.assertEqual(winner, "sleep1")
+ self.assertEqual(index, 1)
+ assert index is not None
+ self.assertIsNone(excs[index])
+ self.assertIsInstance(excs[0], asyncio.CancelledError)
+ self.assertIsInstance(excs[2], ValueError)
+
+ self.loop.run_until_complete(run())
+
+ def test_staggered_race_with_eager_tasks_no_delay(self):
+ # See https://github.com/python/cpython/issues/124309
+ async def fail():
+ raise ValueError("no good")
+
+ async def run():
+ winner, index, excs = await staggered_race(
+ [
+ lambda: fail(),
+ lambda: asyncio.sleep(1, result="sleep1"),
+ lambda: asyncio.sleep(0, result="sleep0"),
+ ],
+ delay=None,
+ )
+ self.assertEqual(winner, "sleep1")
+ self.assertEqual(index, 1)
+ assert index is not None
+ self.assertIsNone(excs[index])
+ self.assertIsInstance(excs[0], ValueError)
+ self.assertEqual(len(excs), 2)
+
+ self.loop.run_until_complete(run())
+
+
+if __name__ == "__main__":
+ if sys.version_info >= (3, 12):
+ unittest.main()
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..b58bd95
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,185 @@
+import socket
+from typing import List
+
+import pytest
+
+from aiohappyeyeballs import (
+ AddrInfoType,
+ addr_to_addr_infos,
+ pop_addr_infos_interleave,
+ remove_addr_infos,
+)
+
+
+def test_pop_addr_infos_interleave():
+ """Test pop_addr_infos_interleave."""
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ addr_info_copy = addr_info.copy()
+ pop_addr_infos_interleave(addr_info_copy, 1)
+ assert addr_info_copy == [ipv6_addr_info_2]
+ pop_addr_infos_interleave(addr_info_copy, 1)
+ assert addr_info_copy == []
+ addr_info_copy = addr_info.copy()
+ pop_addr_infos_interleave(addr_info_copy, 2)
+ assert addr_info_copy == []
+ addr_info_copy = addr_info.copy()
+ pop_addr_infos_interleave(addr_info_copy)
+ assert addr_info_copy == [ipv6_addr_info_2]
+
+
+def test_remove_addr_infos():
+ """Test remove_addr_infos."""
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ addr_info_copy = addr_info.copy()
+ remove_addr_infos(
+ addr_info_copy,
+ ("dead:beef::", 80, 0, 0),
+ )
+ assert addr_info_copy == [ipv6_addr_info_2, ipv4_addr_info]
+ remove_addr_infos(addr_info_copy, ("dead:aaaa::", 80, 0, 0))
+ assert addr_info_copy == [ipv4_addr_info]
+ remove_addr_infos(addr_info_copy, ("107.6.106.83", 80))
+ assert addr_info_copy == []
+
+
+def test_remove_addr_infos_slow_path():
+ """Test remove_addr_infos with mis-matched formatting."""
+ ipv6_addr_info = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:beef::", 80, 0, 0),
+ )
+ ipv6_addr_info_2 = (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ipv4_addr_info = (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("107.6.106.83", 80),
+ )
+ addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info]
+ addr_info_copy = addr_info.copy()
+ remove_addr_infos(
+ addr_info_copy, ("dead:beef:0000:0000:0000:0000:0000:0000", 80, 0, 0)
+ )
+ assert addr_info_copy == [ipv6_addr_info_2, ipv4_addr_info]
+ remove_addr_infos(
+ addr_info_copy, ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0)
+ )
+ assert addr_info_copy == [ipv4_addr_info]
+ with pytest.raises(
+ ValueError, match=r"Address \('107.6.106.2', 80\) not found in addr_infos"
+ ):
+ remove_addr_infos(addr_info_copy, ("107.6.106.2", 80))
+ assert addr_info_copy == [ipv4_addr_info]
+
+
+def test_addr_to_addr_infos():
+ """Test addr_to_addr_infos."""
+ assert addr_to_addr_infos(("1.2.3.4", 43)) == [
+ (
+ socket.AF_INET,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("1.2.3.4", 43),
+ )
+ ]
+ assert addr_to_addr_infos(
+ ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0)
+ ) == [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0),
+ )
+ ]
+ assert addr_to_addr_infos(("dead:aaaa::", 80, 0, 0)) == [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ]
+ assert addr_to_addr_infos(("dead:aaaa::", 80)) == [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 0, 0),
+ )
+ ]
+ assert addr_to_addr_infos(("dead:aaaa::", 80, 1)) == [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 1, 0),
+ )
+ ]
+ assert addr_to_addr_infos(("dead:aaaa::", 80, 1, 1)) == [
+ (
+ socket.AF_INET6,
+ socket.SOCK_STREAM,
+ socket.IPPROTO_TCP,
+ "",
+ ("dead:aaaa::", 80, 1, 1),
+ )
+ ]
+ assert addr_to_addr_infos(None) is None