├── .all-contributorsrc ├── .copier-answers.yml ├── .editorconfig ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1-bug_report.md │ └── 2-feature-request.md ├── dependabot.yml ├── labels.toml └── workflows │ ├── ci-cd.yml │ └── issue-manager.yml ├── .gitignore ├── .gitpod.yml ├── .idea ├── aiohappyeyeballs.iml ├── watcherTasks.xml └── workspace.xml ├── .pre-commit-config.yaml ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.mjs ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── api_reference.rst ├── changelog.md ├── conf.py ├── contributing.md ├── index.md ├── installation.md ├── make.bat └── usage.md ├── poetry.lock ├── pyproject.toml ├── renovate.json ├── setup.py ├── src └── aiohappyeyeballs │ ├── __init__.py │ ├── _staggered.py │ ├── impl.py │ ├── py.typed │ ├── types.py │ └── utils.py ├── templates └── CHANGELOG.md.j2 └── tests ├── __init__.py ├── conftest.py ├── test_impl.py ├── test_init.py ├── test_staggered.py ├── test_staggered_cpython.py ├── test_staggered_cpython_eager_task_factory.py ├── test_types.py └── test_utils.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "aiohappyeyeballs", 3 | "projectOwner": "aio-libs", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 80, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [], 13 | "contributorsPerLine": 7, 14 | "skipCi": true 15 | } 16 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: b09ed7d 3 | _src_path: gh:browniebroke/pypackage-template 4 | add_me_as_contributor: false 5 | copyright_year: '2023' 6 | documentation: true 7 | email: nick@koston.org 8 | full_name: J. Nick Koston 9 | github_username: aio-libs 10 | has_cli: false 11 | initial_commit: true 12 | open_source_license: PSF-2.0 13 | package_name: aiohappyeyeballs 14 | project_name: aiohappyeyeballs 15 | project_short_description: Happy Eyeballs 16 | project_slug: aiohappyeyeballs 17 | run_poetry_install: true 18 | setup_github: true 19 | setup_pre_commit: true 20 | 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # These are supported funding model platforms 3 | 4 | github: 5 | - webknjaz 6 | - Dreamsorcerer 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | labels: bug 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 13 | **Additional context** 14 | Add any other context about the problem here. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | labels: enhancement 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Additional context** 14 | Add any other context or screenshots about the feature request here. 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | commit-message: 13 | prefix: "chore(deps-ci): " 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" 18 | - package-ecosystem: "pip" # See documentation for possible values 19 | directory: "/" # Location of package manifests 20 | schedule: 21 | interval: "weekly" 22 | -------------------------------------------------------------------------------- /.github/labels.toml: -------------------------------------------------------------------------------- 1 | [breaking] 2 | color = "ffcc00" 3 | name = "breaking" 4 | description = "Breaking change." 5 | 6 | [bug] 7 | color = "d73a4a" 8 | name = "bug" 9 | description = "Something isn't working" 10 | 11 | [dependencies] 12 | color = "0366d6" 13 | name = "dependencies" 14 | description = "Pull requests that update a dependency file" 15 | 16 | [github_actions] 17 | color = "000000" 18 | name = "github_actions" 19 | description = "Update of github actions" 20 | 21 | [documentation] 22 | color = "1bc4a5" 23 | name = "documentation" 24 | description = "Improvements or additions to documentation" 25 | 26 | [duplicate] 27 | color = "cfd3d7" 28 | name = "duplicate" 29 | description = "This issue or pull request already exists" 30 | 31 | [enhancement] 32 | color = "a2eeef" 33 | name = "enhancement" 34 | description = "New feature or request" 35 | 36 | ["good first issue"] 37 | color = "7057ff" 38 | name = "good first issue" 39 | description = "Good for newcomers" 40 | 41 | ["help wanted"] 42 | color = "008672" 43 | name = "help wanted" 44 | description = "Extra attention is needed" 45 | 46 | [invalid] 47 | color = "e4e669" 48 | name = "invalid" 49 | description = "This doesn't seem right" 50 | 51 | [nochangelog] 52 | color = "555555" 53 | name = "nochangelog" 54 | description = "Exclude pull requests from changelog" 55 | 56 | [question] 57 | color = "d876e3" 58 | name = "question" 59 | description = "Further information is requested" 60 | 61 | [removed] 62 | color = "e99695" 63 | name = "removed" 64 | description = "Removed piece of functionalities." 65 | 66 | [tests] 67 | color = "bfd4f2" 68 | name = "tests" 69 | description = "CI, CD and testing related changes" 70 | 71 | [wontfix] 72 | color = "ffffff" 73 | name = "wontfix" 74 | description = "This will not be worked on" 75 | 76 | [discussion] 77 | color = "c2e0c6" 78 | name = "discussion" 79 | description = "Some discussion around the project" 80 | 81 | [hacktoberfest] 82 | color = "ffa663" 83 | name = "hacktoberfest" 84 | description = "Good issues for Hacktoberfest" 85 | 86 | [answered] 87 | color = "0ee2b6" 88 | name = "answered" 89 | description = "Automatically closes as answered after a delay" 90 | 91 | [waiting] 92 | color = "5f7972" 93 | name = "waiting" 94 | description = "Automatically closes if no answer after a delay" 95 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | lint: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - uses: pre-commit/action@v3.0.1 22 | 23 | # Make sure commit messages follow the conventional commits convention: 24 | # https://www.conventionalcommits.org 25 | commitlint: 26 | name: Lint Commit Messages 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 32 | - uses: wagoid/commitlint-github-action@v6.2.1 33 | 34 | test: 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | python-version: 39 | - "3.9" 40 | - "3.10" 41 | - "3.11" 42 | - "3.12" 43 | - "3.13" 44 | os: 45 | - ubuntu-latest 46 | - windows-latest 47 | - macOS-latest 48 | runs-on: ${{ matrix.os }} 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Set up Python 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | allow-prereleases: true 56 | - uses: snok/install-poetry@v1.4.1 57 | - name: Install Dependencies 58 | run: poetry install 59 | shell: bash 60 | - name: Test with Pytest 61 | run: poetry run pytest --cov-report=xml 62 | shell: bash 63 | - name: Upload coverage to Codecov 64 | uses: codecov/codecov-action@v5.4.2 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | 68 | test_release: 69 | needs: 70 | - test 71 | - lint 72 | - commitlint 73 | 74 | runs-on: ubuntu-latest 75 | environment: test_release 76 | concurrency: release 77 | if: github.ref_name != 'main' 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | with: 82 | fetch-depth: 0 83 | ref: ${{ github.head_ref || github.ref_name }} 84 | 85 | # Dry run of PSR to build the distribution 86 | - name: Test release 87 | uses: python-semantic-release/python-semantic-release@v9.21.0 88 | with: 89 | root_options: --noop 90 | 91 | - uses: snok/install-poetry@v1.4.1 92 | - name: Install Dependencies 93 | run: poetry install --only main,test_build 94 | shell: bash 95 | 96 | - name: Test build of distribution packages 97 | shell: bash 98 | run: | 99 | poetry build 100 | poetry run python -Im twine check --strict dist/* 101 | 102 | build_release: 103 | needs: 104 | - test 105 | - lint 106 | - commitlint 107 | 108 | if: github.ref_name == 'main' && !startsWith(github.event.pull_request.title,'chore') && !startsWith(github.event.head_commit.message,'chore') 109 | 110 | runs-on: ubuntu-latest 111 | outputs: 112 | released: ${{ steps.release.outputs.released }} 113 | 114 | concurrency: release 115 | permissions: 116 | id-token: write 117 | contents: write 118 | 119 | steps: 120 | - uses: actions/checkout@v4 121 | with: 122 | fetch-depth: 0 123 | ref: ${{ github.head_ref || github.ref_name }} 124 | 125 | # On main branch: Call PSR to build the distribution 126 | - name: Release 127 | uses: python-semantic-release/python-semantic-release@v9.21.0 128 | id: release 129 | 130 | with: 131 | github_token: ${{ secrets.GITHUB_TOKEN }} 132 | 133 | - name: Store the distribution packages 134 | uses: actions/upload-artifact@v4 135 | with: 136 | name: python-package-distributions 137 | path: dist/ 138 | 139 | release: 140 | needs: 141 | - build_release 142 | 143 | if: needs.build_release.outputs.released == 'true' 144 | runs-on: ubuntu-latest 145 | environment: pypi 146 | concurrency: release 147 | permissions: 148 | id-token: write 149 | contents: write 150 | 151 | steps: 152 | - name: Download all the dists 153 | uses: actions/download-artifact@v4 154 | with: 155 | name: python-package-distributions 156 | path: dist/ 157 | 158 | - name: Publish package distributions to PyPI 159 | uses: pypa/gh-action-pypi-publish@release/v1 160 | 161 | - uses: actions/checkout@v4 162 | with: 163 | fetch-depth: 0 164 | ref: ${{ github.head_ref || github.ref_name }} 165 | 166 | - name: Publish package distributions to GitHub Releases 167 | uses: python-semantic-release/upload-to-gh-release@v9.8.9 168 | with: 169 | github_token: ${{ secrets.GITHUB_TOKEN }} 170 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | issues: 10 | types: 11 | - labeled 12 | pull_request_target: 13 | types: 14 | - labeled 15 | workflow_dispatch: 16 | 17 | jobs: 18 | issue-manager: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: tiangolo/issue-manager@0.5.1 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | config: > 25 | { 26 | "answered": { 27 | "message": "Assuming the original issue was solved, it will be automatically closed now." 28 | }, 29 | "waiting": { 30 | "message": "Automatically closing. To re-open, please provide the additional information requested." 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder {{package_name}} settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope {{package_name}} settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - command: | 3 | pip install poetry 4 | PIP_USER=false poetry install 5 | - command: | 6 | pip install pre-commit 7 | pre-commit install 8 | PIP_USER=false pre-commit install-hooks 9 | -------------------------------------------------------------------------------- /.idea/aiohappyeyeballs.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 24 | 25 | 36 | 44 | 45 | 56 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /.idea/workspace.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | exclude: "CHANGELOG.md|.copier-answers.yml|.all-contributorsrc" 4 | default_stages: [pre-commit] 5 | 6 | ci: 7 | autofix_commit_msg: "chore(pre-commit.ci): auto fixes" 8 | autoupdate_commit_msg: "chore(pre-commit.ci): pre-commit autoupdate" 9 | 10 | repos: 11 | - repo: https://github.com/commitizen-tools/commitizen 12 | rev: v4.8.2 13 | hooks: 14 | - id: commitizen 15 | stages: [commit-msg] 16 | - repo: https://github.com/pre-commit/pre-commit-hooks 17 | rev: v5.0.0 18 | hooks: 19 | - id: debug-statements 20 | - id: check-builtin-literals 21 | - id: check-case-conflict 22 | - id: check-docstring-first 23 | - id: check-json 24 | - id: check-toml 25 | - id: check-xml 26 | - id: check-yaml 27 | - id: detect-private-key 28 | - id: end-of-file-fixer 29 | - id: trailing-whitespace 30 | - repo: https://github.com/python-poetry/poetry 31 | rev: 2.1.3 32 | hooks: 33 | - id: poetry-check 34 | - repo: https://github.com/pre-commit/mirrors-prettier 35 | rev: v4.0.0-alpha.8 36 | hooks: 37 | - id: prettier 38 | args: ["--tab-width", "2"] 39 | - repo: https://github.com/astral-sh/ruff-pre-commit 40 | rev: v0.11.11 41 | hooks: 42 | - id: ruff 43 | args: [--fix, --exit-non-zero-on-fix] 44 | # Run the formatter. 45 | - id: ruff-format 46 | - repo: https://github.com/codespell-project/codespell 47 | rev: v2.4.1 48 | hooks: 49 | - id: codespell 50 | - repo: https://github.com/pre-commit/mirrors-mypy 51 | rev: v1.15.0 52 | hooks: 53 | - id: mypy 54 | additional_dependencies: [] 55 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-20.04 10 | tools: 11 | python: "3.12" 12 | jobs: 13 | post_create_environment: 14 | # Install poetry 15 | - pip install poetry 16 | post_install: 17 | # Install dependencies, reusing RTD virtualenv 18 | - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH poetry install --with docs 19 | 20 | # Build documentation in the docs directory with Sphinx 21 | sphinx: 22 | configuration: docs/conf.py 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.6.1 (2025-03-12) 4 | 5 | ### Bug fixes 6 | 7 | - Resolve typeerror on import for python < 3.9.2 (#151) ([`2042c82`](https://github.com/aio-libs/aiohappyeyeballs/commit/2042c82f9978f41c31b58aa4e3d8fc3b9c3ec2ec)) 8 | 9 | ## v2.6.0 (2025-03-11) 10 | 11 | ### Features 12 | 13 | - Publish documentation (#149) ([`4235273`](https://github.com/aio-libs/aiohappyeyeballs/commit/42352736d12c60d500c63b9598ffab05ef5e8829)) 14 | 15 | ## v2.5.0 (2025-03-06) 16 | 17 | ### Features 18 | 19 | - Add callback for users to customize socket creation (#147) ([`8e1bc6a`](https://github.com/aio-libs/aiohappyeyeballs/commit/8e1bc6a4bc6282ccf29db441c33dd8d806003ffd)) 20 | 21 | ## v2.4.8 (2025-03-04) 22 | 23 | ### Bug fixes 24 | 25 | - Close runner up sockets in the event there are multiple winners (#143) ([`476a05b`](https://github.com/aio-libs/aiohappyeyeballs/commit/476a05b956627700baa84eb6aac28c395da92a9f)) 26 | 27 | ## v2.4.7 (2025-03-04) 28 | 29 | ### Bug fixes 30 | 31 | - Resolve warnings when running tests (#144) ([`e96264a`](https://github.com/aio-libs/aiohappyeyeballs/commit/e96264aec89b9bd34d37413f610d039c56393a48)) 32 | 33 | ## v2.4.6 (2025-02-07) 34 | 35 | ### Bug fixes 36 | 37 | - Ensure all timers are cancelled when after staggered race finishes (#136) ([`f75891d`](https://github.com/aio-libs/aiohappyeyeballs/commit/f75891d8974693b24af9789a8981ed7f6bc55c5c)) 38 | 39 | ## v2.4.5 (2025-02-07) 40 | 41 | ### Bug fixes 42 | 43 | - Keep classifiers in project to avoid automatic enrichment (#134) ([`99edb20`](https://github.com/aio-libs/aiohappyeyeballs/commit/99edb20e9d3e53ead65b55cb3e93c22c03d06599)) 44 | - Move classifiers to prevent recalculation by poetry (#131) ([`66e1c90`](https://github.com/aio-libs/aiohappyeyeballs/commit/66e1c90ae81f71c7039cd62c60417a96202d906c)) 45 | 46 | ## v2.4.4 (2024-11-30) 47 | 48 | ### Bug fixes 49 | 50 | - Handle oserror on failure to close socket instead of raising indexerror (#114) ([`c542f68`](https://github.com/aio-libs/aiohappyeyeballs/commit/c542f684d329fed04093caa2b31d8f7f6e0e0949)) 51 | 52 | ## v2.4.3 (2024-09-30) 53 | 54 | ### Bug fixes 55 | 56 | - Rewrite staggered_race to be race safe (#101) ([`9db617a`](https://github.com/aio-libs/aiohappyeyeballs/commit/9db617a982ee27994bf13c805f9c4f054f05de47)) 57 | - Re-raise runtimeerror when uvloop raises runtimeerror during connect (#105) ([`c8f1fa9`](https://github.com/aio-libs/aiohappyeyeballs/commit/c8f1fa93d698f216f84de7074a6282777fbf0439)) 58 | 59 | ## v2.4.2 (2024-09-27) 60 | 61 | ### Bug fixes 62 | 63 | - Copy staggered from standard lib for python 3.12+ (#95) ([`c5a4023`](https://github.com/aio-libs/aiohappyeyeballs/commit/c5a4023d904b3e72f30b8a9f56913894dda4c9d0)) 64 | 65 | ## v2.4.1 (2024-09-26) 66 | 67 | ### Bug fixes 68 | 69 | - Avoid passing loop to staggered.staggered_race (#94) ([`5f80b79`](https://github.com/aio-libs/aiohappyeyeballs/commit/5f80b7951f32d727039d9db776a17a6eba8877cd)) 70 | 71 | ## v2.4.0 (2024-08-19) 72 | 73 | ### Features 74 | 75 | - Add support for python 3.13 (#86) ([`4f2152f`](https://github.com/aio-libs/aiohappyeyeballs/commit/4f2152fbb6b1d915c2fd68219339d998c47a71f9)) 76 | 77 | ### Documentation 78 | 79 | - Fix a trivial typo in readme.md (#84) ([`f5ae7d4`](https://github.com/aio-libs/aiohappyeyeballs/commit/f5ae7d4bce04ee0645257ac828745a3b989ef149)) 80 | 81 | ## v2.3.7 (2024-08-17) 82 | 83 | ### Bug fixes 84 | 85 | - Correct classifier for license python-2.0.1 (#83) ([`186be05`](https://github.com/aio-libs/aiohappyeyeballs/commit/186be056ea441bb3fa7620864f46c6f8431f3a34)) 86 | 87 | ## v2.3.6 (2024-08-16) 88 | 89 | ### Bug fixes 90 | 91 | - Adjust license to python-2.0.1 (#82) ([`30a2dc5`](https://github.com/aio-libs/aiohappyeyeballs/commit/30a2dc57c49d1000ebdafa8c81ecf4f79e35c9f3)) 92 | 93 | ## v2.3.5 (2024-08-07) 94 | 95 | ### Bug fixes 96 | 97 | - Remove upper bound on python requirement (#74) ([`0de1e53`](https://github.com/aio-libs/aiohappyeyeballs/commit/0de1e534fc5b7526e11bf203ab09b95b13f3070b)) 98 | - Preserve errno if all exceptions have the same errno (#77) ([`7bbb2bf`](https://github.com/aio-libs/aiohappyeyeballs/commit/7bbb2bf0feb3994953a52a1d606e682acad49cb8)) 99 | - Adjust license classifier to better reflect license terms (#78) ([`56e7ba6`](https://github.com/aio-libs/aiohappyeyeballs/commit/56e7ba612c5029364bb960b07022a2b720f0a967)) 100 | 101 | ### Documentation 102 | 103 | - Add link to happy eyeballs explanation (#73) ([`077710c`](https://github.com/aio-libs/aiohappyeyeballs/commit/077710c150b6c762ffe408e0ad418c506a2d6f31)) 104 | 105 | ## v2.3.4 (2024-07-31) 106 | 107 | ### Bug fixes 108 | 109 | - Add missing asyncio to fix truncated package description (#67) ([`2644df1`](https://github.com/aio-libs/aiohappyeyeballs/commit/2644df179e21e4513da857f2aea2aa64a3fb6316)) 110 | 111 | ## v2.3.3 (2024-07-31) 112 | 113 | ### Bug fixes 114 | 115 | - Add missing python version classifiers (#65) ([`489016f`](https://github.com/aio-libs/aiohappyeyeballs/commit/489016feb53d4fd5f9880f27dc40a5198d5b0be2)) 116 | - Update classifiers to include license (#60) ([`a746c29`](https://github.com/aio-libs/aiohappyeyeballs/commit/a746c296b324407efef272f422a990587b9d6057)) 117 | - Workaround broken `asyncio.staggered` on python < 3.8.2 (#61) ([`b16f107`](https://github.com/aio-libs/aiohappyeyeballs/commit/b16f107d9493817247c27ab83522901f086a13b5)) 118 | - Include tests in the source distribution package (#62) ([`53053b6`](https://github.com/aio-libs/aiohappyeyeballs/commit/53053b6a38ef868e0170940ced5e0611ebd1be4c)) 119 | 120 | ## v2.3.2 (2024-01-06) 121 | 122 | ### Bug fixes 123 | 124 | - Update urls for the new home for this library (#43) ([`c6d4358`](https://github.com/aio-libs/aiohappyeyeballs/commit/c6d43586d5ca56472892767d4a47d28348158544)) 125 | 126 | ## v2.3.1 (2023-12-14) 127 | 128 | ### Bug fixes 129 | 130 | - Remove test import from tests (#31) ([`c529b15`](https://github.com/aio-libs/aiohappyeyeballs/commit/c529b15fbead0aa5cde9dd5c460ff5abd15fc355)) 131 | 132 | ## v2.3.0 (2023-12-12) 133 | 134 | ### Features 135 | 136 | - Avoid _interleave_addrinfos when there is only a single addr_info (#29) ([`305f6f1`](https://github.com/aio-libs/aiohappyeyeballs/commit/305f6f13d028ab3ead7923870601175102c5970c)) 137 | 138 | ## v2.2.0 (2023-12-11) 139 | 140 | ### Features 141 | 142 | - Make interleave with pop_addr_infos_interleave optional to match cpython (#28) ([`adbc8ad`](https://github.com/aio-libs/aiohappyeyeballs/commit/adbc8adfaa44349ca83966787400413668f0b4b6)) 143 | 144 | ## v2.1.0 (2023-12-11) 145 | 146 | ### Features 147 | 148 | - Add addr_to_addr_info util for converting addr to addr_infos (#27) ([`2e25a98`](https://github.com/aio-libs/aiohappyeyeballs/commit/2e25a98f2339d84bc7951ad17f0b38c104a97a71)) 149 | 150 | ## v2.0.0 (2023-12-10) 151 | 152 | ### Features 153 | 154 | - Require the full address tuple for the remove_addr_infos util (#26) ([`d7e5df1`](https://github.com/aio-libs/aiohappyeyeballs/commit/d7e5df12a01838e81729af4c49938e98b3407e03)) 155 | 156 | ## v1.8.1 (2023-12-10) 157 | 158 | ### Bug fixes 159 | 160 | - Move types into a single file (#24) ([`8d4cfee`](https://github.com/aio-libs/aiohappyeyeballs/commit/8d4cfeeaa7862e028e941c49f8c84dcee0b9b1ac)) 161 | 162 | ## v1.8.0 (2023-12-10) 163 | 164 | ### Features 165 | 166 | - Add utils (#23) ([`d89311d`](https://github.com/aio-libs/aiohappyeyeballs/commit/d89311d1a433dde75863019a08717a531f68befa)) 167 | 168 | ## v1.7.0 (2023-12-09) 169 | 170 | ### Bug fixes 171 | 172 | - License should be psf-2.0 (#22) ([`ca9c1fc`](https://github.com/aio-libs/aiohappyeyeballs/commit/ca9c1fca4d63c54855fbe582132b5dcb229c7591)) 173 | 174 | ### Features 175 | 176 | - Add some more examples to the docs (#21) ([`6cd0b5d`](https://github.com/aio-libs/aiohappyeyeballs/commit/6cd0b5d10357a9d20fc5ee1c96db18c6994cd8fc)) 177 | 178 | ## v1.6.0 (2023-12-09) 179 | 180 | ### Features 181 | 182 | - Add coverage for multiple and same exceptions (#20) ([`2781b87`](https://github.com/aio-libs/aiohappyeyeballs/commit/2781b87c56aa1c08345d91dce5c1642f2b3e396d)) 183 | 184 | ## v1.5.0 (2023-12-09) 185 | 186 | ### Features 187 | 188 | - Add coverage for setblocking failing (#19) ([`f759a08`](https://github.com/aio-libs/aiohappyeyeballs/commit/f759a08180f0237cb68d353090f7ba0efe625074)) 189 | - Add cover for passing the loop (#18) ([`2d26911`](https://github.com/aio-libs/aiohappyeyeballs/commit/2d26911e9237691c168a705b2d6be2a68fa8b7ac)) 190 | 191 | ## v1.4.1 (2023-12-09) 192 | 193 | ### Bug fixes 194 | 195 | - Ensure exception error is stringified (#17) ([`747cf1d`](https://github.com/aio-libs/aiohappyeyeballs/commit/747cf1d231dc427b79ff1f8128779413a50be5d8)) 196 | 197 | ## v1.4.0 (2023-12-09) 198 | 199 | ### Features 200 | 201 | - Add coverage for unexpected exception (#16) ([`bad4874`](https://github.com/aio-libs/aiohappyeyeballs/commit/bad48745d3621fcbbe559d55180dc5f5856dc0fa)) 202 | 203 | ## v1.3.0 (2023-12-09) 204 | 205 | ### Features 206 | 207 | - Add coverage for bind failure with local addresses (#15) ([`f71ec23`](https://github.com/aio-libs/aiohappyeyeballs/commit/f71ec23228d4dad4bc2c3a6630e6e4361b54df44)) 208 | 209 | ## v1.2.0 (2023-12-09) 210 | 211 | ### Features 212 | 213 | - Add coverage for passing local addresses (#14) ([`72a92e3`](https://github.com/aio-libs/aiohappyeyeballs/commit/72a92e3a599cde082856354e806a793f2b9eff62)) 214 | 215 | ## v1.1.0 (2023-12-09) 216 | 217 | ### Features 218 | 219 | - Add example usage (#13) ([`707fddc`](https://github.com/aio-libs/aiohappyeyeballs/commit/707fddcd8e8aff27af2180af6271898003ca1782)) 220 | 221 | ## v1.0.0 (2023-12-09) 222 | 223 | ### Features 224 | 225 | - Rename create_connection to start_connection (#12) ([`f8b6038`](https://github.com/aio-libs/aiohappyeyeballs/commit/f8b60383d9b9f013baf421ad4e4e183559b7a705)) 226 | 227 | ## v0.9.0 (2023-12-09) 228 | 229 | ### Features 230 | 231 | - Add coverage for interleave (#11) ([`62817f1`](https://github.com/aio-libs/aiohappyeyeballs/commit/62817f1473bb5702f8fa9edc6f6b24139990cd01)) 232 | 233 | ## v0.8.0 (2023-12-09) 234 | 235 | ### Features 236 | 237 | - Add coverage for multi ipv6 (#10) ([`6dc8f89`](https://github.com/aio-libs/aiohappyeyeballs/commit/6dc8f89ff99a38c8ecaf8045c9afbe683d6f2c6e)) 238 | 239 | ## v0.7.0 (2023-12-09) 240 | 241 | ### Features 242 | 243 | - Add coverage for ipv6 failure (#9) ([`7aee8f6`](https://github.com/aio-libs/aiohappyeyeballs/commit/7aee8f64064cfc8d79f385c4dfee45036aacd6fd)) 244 | 245 | ## v0.6.0 (2023-12-09) 246 | 247 | ### Features 248 | 249 | - Improve test coverage (#8) ([`afcfe5a`](https://github.com/aio-libs/aiohappyeyeballs/commit/afcfe5a350acc50a098009617511cd9d21b22f47)) 250 | 251 | ## v0.5.0 (2023-12-09) 252 | 253 | ### Features 254 | 255 | - Improve doc strings (#7) ([`3d5f7fd`](https://github.com/aio-libs/aiohappyeyeballs/commit/3d5f7fde55c4bdd4f5e6cff589ae9b47b279d663)) 256 | 257 | ## v0.4.0 (2023-12-09) 258 | 259 | ### Features 260 | 261 | - Add more tests (#6) ([`4428c07`](https://github.com/aio-libs/aiohappyeyeballs/commit/4428c0714e3e100605f940eb6adee2e86788b4db)) 262 | 263 | ## v0.3.0 (2023-12-09) 264 | 265 | ### Features 266 | 267 | - Optimize for single case (#5) ([`c7d72f3`](https://github.com/aio-libs/aiohappyeyeballs/commit/c7d72f3cdd13149319fc9e4848146d23bddc619b)) 268 | 269 | ## v0.2.0 (2023-12-09) 270 | 271 | ### Features 272 | 273 | - Optimize for single case (#4) ([`d371c46`](https://github.com/aio-libs/aiohappyeyeballs/commit/d371c4687d3b3861a4f0287ac5229853f895807b)) 274 | 275 | ## v0.1.0 (2023-12-09) 276 | 277 | ### Features 278 | 279 | - Init (#2) ([`c9a9099`](https://github.com/aio-libs/aiohappyeyeballs/commit/c9a90994a40d5f49cb37d3e2708db4b4278649ef)) 280 | 281 | ## v0.0.1 (2023-12-09) 282 | 283 | ### Bug fixes 284 | 285 | - Reserve name on pypi (#1) ([`2207f8d`](https://github.com/aio-libs/aiohappyeyeballs/commit/2207f8d361af4ec0b853b07fb743eb957a0b368a)) 286 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little helps, and credit will always be given. 4 | 5 | You can contribute in many ways: 6 | 7 | ## Types of Contributions 8 | 9 | ### Report Bugs 10 | 11 | Report bugs to [our issue page][gh-issues]. If you are reporting a bug, please include: 12 | 13 | - Your operating system name and version. 14 | - Any details about your local setup that might be helpful in troubleshooting. 15 | - Detailed steps to reproduce the bug. 16 | 17 | ### Fix Bugs 18 | 19 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. 20 | 21 | ### Implement Features 22 | 23 | Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. 24 | 25 | ### Write Documentation 26 | 27 | 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. 28 | 29 | ### Submit Feedback 30 | 31 | The best way to send feedback [our issue page][gh-issues] on GitHub. If you are proposing a feature: 32 | 33 | - Explain in detail how it would work. 34 | - Keep the scope as narrow as possible, to make it easier to implement. 35 | - Remember that this is a volunteer-driven project, and that contributions are welcome 😊 36 | 37 | ## Get Started! 38 | 39 | Ready to contribute? Here's how to set yourself up for local development. 40 | 41 | 1. Fork the repo on GitHub. 42 | 43 | 2. Clone your fork locally: 44 | 45 | ```shell 46 | $ git clone git@github.com:your_name_here/aiohappyeyeballs.git 47 | ``` 48 | 49 | 3. Install the project dependencies with [Poetry](https://python-poetry.org): 50 | 51 | ```shell 52 | $ poetry install 53 | ``` 54 | 55 | 4. Create a branch for local development: 56 | 57 | ```shell 58 | $ git checkout -b name-of-your-bugfix-or-feature 59 | ``` 60 | 61 | Now you can make your changes locally. 62 | 63 | 5. When you're done making changes, check that your changes pass our tests: 64 | 65 | ```shell 66 | $ poetry run pytest 67 | ``` 68 | 69 | 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: 70 | 71 | ```shell 72 | $ pre-commit run -a 73 | ``` 74 | 75 | Or better, install the hooks once and have them run automatically each time you commit: 76 | 77 | ```shell 78 | $ pre-commit install 79 | ``` 80 | 81 | 7. Commit your changes and push your branch to GitHub: 82 | 83 | ```shell 84 | $ git add . 85 | $ git commit -m "feat(something): your detailed description of your changes" 86 | $ git push origin name-of-your-bugfix-or-feature 87 | ``` 88 | 89 | 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. 90 | 91 | 8. Submit a pull request through the GitHub website or using the GitHub CLI (if you have it installed): 92 | 93 | ```shell 94 | $ gh pr create --fill 95 | ``` 96 | 97 | ## Pull Request Guidelines 98 | 99 | 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: 100 | 101 | 1. Include tests for feature or bug fixes. 102 | 2. Update the documentation for significant features. 103 | 3. Ensure tests are passing on CI. 104 | 105 | ## Tips 106 | 107 | To run a subset of tests: 108 | 109 | ```shell 110 | $ pytest tests 111 | ``` 112 | 113 | ## Making a new release 114 | 115 | 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. 116 | 117 | [gh-issues]: https://github.com/aio-libs/aiohappyeyeballs/issues 118 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | A. HISTORY OF THE SOFTWARE 2 | ========================== 3 | 4 | Python was created in the early 1990s by Guido van Rossum at Stichting 5 | Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands 6 | as a successor of a language called ABC. Guido remains Python's 7 | principal author, although it includes many contributions from others. 8 | 9 | In 1995, Guido continued his work on Python at the Corporation for 10 | National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) 11 | in Reston, Virginia where he released several versions of the 12 | software. 13 | 14 | In May 2000, Guido and the Python core development team moved to 15 | BeOpen.com to form the BeOpen PythonLabs team. In October of the same 16 | year, the PythonLabs team moved to Digital Creations, which became 17 | Zope Corporation. In 2001, the Python Software Foundation (PSF, see 18 | https://www.python.org/psf/) was formed, a non-profit organization 19 | created specifically to own Python-related Intellectual Property. 20 | Zope Corporation was a sponsoring member of the PSF. 21 | 22 | All Python releases are Open Source (see https://opensource.org for 23 | the Open Source Definition). Historically, most, but not all, Python 24 | releases have also been GPL-compatible; the table below summarizes 25 | the various releases. 26 | 27 | Release Derived Year Owner GPL- 28 | from compatible? (1) 29 | 30 | 0.9.0 thru 1.2 1991-1995 CWI yes 31 | 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes 32 | 1.6 1.5.2 2000 CNRI no 33 | 2.0 1.6 2000 BeOpen.com no 34 | 1.6.1 1.6 2001 CNRI yes (2) 35 | 2.1 2.0+1.6.1 2001 PSF no 36 | 2.0.1 2.0+1.6.1 2001 PSF yes 37 | 2.1.1 2.1+2.0.1 2001 PSF yes 38 | 2.1.2 2.1.1 2002 PSF yes 39 | 2.1.3 2.1.2 2002 PSF yes 40 | 2.2 and above 2.1.1 2001-now PSF yes 41 | 42 | Footnotes: 43 | 44 | (1) GPL-compatible doesn't mean that we're distributing Python under 45 | the GPL. All Python licenses, unlike the GPL, let you distribute 46 | a modified version without making your changes open source. The 47 | GPL-compatible licenses make it possible to combine Python with 48 | other software that is released under the GPL; the others don't. 49 | 50 | (2) According to Richard Stallman, 1.6.1 is not GPL-compatible, 51 | because its license has a choice of law clause. According to 52 | CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 53 | is "not incompatible" with the GPL. 54 | 55 | Thanks to the many outside volunteers who have worked under Guido's 56 | direction to make these releases possible. 57 | 58 | 59 | B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON 60 | =============================================================== 61 | 62 | Python software and documentation are licensed under the 63 | Python Software Foundation License Version 2. 64 | 65 | Starting with Python 3.8.6, examples, recipes, and other code in 66 | the documentation are dual licensed under the PSF License Version 2 67 | and the Zero-Clause BSD license. 68 | 69 | Some software incorporated into Python is under different licenses. 70 | The licenses are listed with code falling under that license. 71 | 72 | 73 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 74 | -------------------------------------------- 75 | 76 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 77 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 78 | otherwise using this software ("Python") in source or binary form and 79 | its associated documentation. 80 | 81 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 82 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 83 | analyze, test, perform and/or display publicly, prepare derivative works, 84 | distribute, and otherwise use Python alone or in any derivative version, 85 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 86 | i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 87 | 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; 88 | All Rights Reserved" are retained in Python alone or in any derivative version 89 | prepared by Licensee. 90 | 91 | 3. In the event Licensee prepares a derivative work that is based on 92 | or incorporates Python or any part thereof, and wants to make 93 | the derivative work available to others as provided herein, then 94 | Licensee hereby agrees to include in any such work a brief summary of 95 | the changes made to Python. 96 | 97 | 4. PSF is making Python available to Licensee on an "AS IS" 98 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 99 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 100 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 101 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 102 | INFRINGE ANY THIRD PARTY RIGHTS. 103 | 104 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 105 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 106 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 107 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 108 | 109 | 6. This License Agreement will automatically terminate upon a material 110 | breach of its terms and conditions. 111 | 112 | 7. Nothing in this License Agreement shall be deemed to create any 113 | relationship of agency, partnership, or joint venture between PSF and 114 | Licensee. This License Agreement does not grant permission to use PSF 115 | trademarks or trade name in a trademark sense to endorse or promote 116 | products or services of Licensee, or any third party. 117 | 118 | 8. By copying, installing or otherwise using Python, Licensee 119 | agrees to be bound by the terms and conditions of this License 120 | Agreement. 121 | 122 | 123 | BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 124 | ------------------------------------------- 125 | 126 | BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 127 | 128 | 1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an 129 | office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the 130 | Individual or Organization ("Licensee") accessing and otherwise using 131 | this software in source or binary form and its associated 132 | documentation ("the Software"). 133 | 134 | 2. Subject to the terms and conditions of this BeOpen Python License 135 | Agreement, BeOpen hereby grants Licensee a non-exclusive, 136 | royalty-free, world-wide license to reproduce, analyze, test, perform 137 | and/or display publicly, prepare derivative works, distribute, and 138 | otherwise use the Software alone or in any derivative version, 139 | provided, however, that the BeOpen Python License is retained in the 140 | Software, alone or in any derivative version prepared by Licensee. 141 | 142 | 3. BeOpen is making the Software available to Licensee on an "AS IS" 143 | basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 144 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND 145 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 146 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT 147 | INFRINGE ANY THIRD PARTY RIGHTS. 148 | 149 | 4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE 150 | SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS 151 | AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY 152 | DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 153 | 154 | 5. This License Agreement will automatically terminate upon a material 155 | breach of its terms and conditions. 156 | 157 | 6. This License Agreement shall be governed by and interpreted in all 158 | respects by the law of the State of California, excluding conflict of 159 | law provisions. Nothing in this License Agreement shall be deemed to 160 | create any relationship of agency, partnership, or joint venture 161 | between BeOpen and Licensee. This License Agreement does not grant 162 | permission to use BeOpen trademarks or trade names in a trademark 163 | sense to endorse or promote products or services of Licensee, or any 164 | third party. As an exception, the "BeOpen Python" logos available at 165 | http://www.pythonlabs.com/logos.html may be used according to the 166 | permissions granted on that web page. 167 | 168 | 7. By copying, installing or otherwise using the software, Licensee 169 | agrees to be bound by the terms and conditions of this License 170 | Agreement. 171 | 172 | 173 | CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 174 | --------------------------------------- 175 | 176 | 1. This LICENSE AGREEMENT is between the Corporation for National 177 | Research Initiatives, having an office at 1895 Preston White Drive, 178 | Reston, VA 20191 ("CNRI"), and the Individual or Organization 179 | ("Licensee") accessing and otherwise using Python 1.6.1 software in 180 | source or binary form and its associated documentation. 181 | 182 | 2. Subject to the terms and conditions of this License Agreement, CNRI 183 | hereby grants Licensee a nonexclusive, royalty-free, world-wide 184 | license to reproduce, analyze, test, perform and/or display publicly, 185 | prepare derivative works, distribute, and otherwise use Python 1.6.1 186 | alone or in any derivative version, provided, however, that CNRI's 187 | License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) 188 | 1995-2001 Corporation for National Research Initiatives; All Rights 189 | Reserved" are retained in Python 1.6.1 alone or in any derivative 190 | version prepared by Licensee. Alternately, in lieu of CNRI's License 191 | Agreement, Licensee may substitute the following text (omitting the 192 | quotes): "Python 1.6.1 is made available subject to the terms and 193 | conditions in CNRI's License Agreement. This Agreement together with 194 | Python 1.6.1 may be located on the internet using the following 195 | unique, persistent identifier (known as a handle): 1895.22/1013. This 196 | Agreement may also be obtained from a proxy server on the internet 197 | using the following URL: http://hdl.handle.net/1895.22/1013". 198 | 199 | 3. In the event Licensee prepares a derivative work that is based on 200 | or incorporates Python 1.6.1 or any part thereof, and wants to make 201 | the derivative work available to others as provided herein, then 202 | Licensee hereby agrees to include in any such work a brief summary of 203 | the changes made to Python 1.6.1. 204 | 205 | 4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" 206 | basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 207 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND 208 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 209 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT 210 | INFRINGE ANY THIRD PARTY RIGHTS. 211 | 212 | 5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 213 | 1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 214 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, 215 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 216 | 217 | 6. This License Agreement will automatically terminate upon a material 218 | breach of its terms and conditions. 219 | 220 | 7. This License Agreement shall be governed by the federal 221 | intellectual property law of the United States, including without 222 | limitation the federal copyright law, and, to the extent such 223 | U.S. federal law does not apply, by the law of the Commonwealth of 224 | Virginia, excluding Virginia's conflict of law provisions. 225 | Notwithstanding the foregoing, with regard to derivative works based 226 | on Python 1.6.1 that incorporate non-separable material that was 227 | previously distributed under the GNU General Public License (GPL), the 228 | law of the Commonwealth of Virginia shall govern this License 229 | Agreement only as to issues arising under or with respect to 230 | Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this 231 | License Agreement shall be deemed to create any relationship of 232 | agency, partnership, or joint venture between CNRI and Licensee. This 233 | License Agreement does not grant permission to use CNRI trademarks or 234 | trade name in a trademark sense to endorse or promote products or 235 | services of Licensee, or any third party. 236 | 237 | 8. By clicking on the "ACCEPT" button where indicated, or by copying, 238 | installing or otherwise using Python 1.6.1, Licensee agrees to be 239 | bound by the terms and conditions of this License Agreement. 240 | 241 | ACCEPT 242 | 243 | 244 | CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 245 | -------------------------------------------------- 246 | 247 | Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, 248 | The Netherlands. All rights reserved. 249 | 250 | Permission to use, copy, modify, and distribute this software and its 251 | documentation for any purpose and without fee is hereby granted, 252 | provided that the above copyright notice appear in all copies and that 253 | both that copyright notice and this permission notice appear in 254 | supporting documentation, and that the name of Stichting Mathematisch 255 | Centrum or CWI not be used in advertising or publicity pertaining to 256 | distribution of the software without specific, written prior 257 | permission. 258 | 259 | STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO 260 | THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 261 | FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE 262 | FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 263 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 264 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 265 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 266 | 267 | ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION 268 | ---------------------------------------------------------------------- 269 | 270 | Permission to use, copy, modify, and/or distribute this software for any 271 | purpose with or without fee is hereby granted. 272 | 273 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 274 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 275 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 276 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 277 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 278 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 279 | PERFORMANCE OF THIS SOFTWARE. 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiohappyeyeballs 2 | 3 |

4 | 5 | CI Status 6 | 7 | 8 | Documentation Status 9 | 10 | 11 | Test coverage percentage 12 | 13 |

14 |

15 | 16 | Poetry 17 | 18 | 19 | Ruff 20 | 21 | 22 | pre-commit 23 | 24 |

25 |

26 | 27 | PyPI Version 28 | 29 | Supported Python versions 30 | License 31 |

32 | 33 | --- 34 | 35 | **Documentation**: https://aiohappyeyeballs.readthedocs.io 36 | 37 | **Source Code**: https://github.com/aio-libs/aiohappyeyeballs 38 | 39 | --- 40 | 41 | [Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) 42 | ([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html)) 43 | 44 | ## Use case 45 | 46 | This library exists to allow connecting with 47 | [Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs) 48 | ([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html)) 49 | when you 50 | already have a list of addrinfo and not a DNS name. 51 | 52 | The stdlib version of `loop.create_connection()` 53 | will only work when you pass in an unresolved name which 54 | is not a good fit when using DNS caching or resolving 55 | names via another method such as `zeroconf`. 56 | 57 | ## Installation 58 | 59 | Install this via pip (or your favourite package manager): 60 | 61 | `pip install aiohappyeyeballs` 62 | 63 | ## License 64 | 65 | [aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE) 66 | 67 | ## Example usage 68 | 69 | ```python 70 | 71 | addr_infos = await loop.getaddrinfo("example.org", 80) 72 | 73 | socket = await start_connection(addr_infos) 74 | socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2) 75 | 76 | transport, protocol = await loop.create_connection( 77 | MyProtocol, sock=socket, ...) 78 | 79 | # Remove the first address for each family from addr_info 80 | pop_addr_infos_interleave(addr_info, 1) 81 | 82 | # Remove all matching address from addr_info 83 | remove_addr_infos(addr_info, "dead::beef::") 84 | 85 | # Convert a local_addr to local_addr_infos 86 | local_addr_infos = addr_to_addr_infos(("127.0.0.1",0)) 87 | ``` 88 | 89 | ## Credits 90 | 91 | This package contains code from cpython and is licensed under the same terms as cpython itself. 92 | 93 | This package was created with 94 | [Copier](https://copier.readthedocs.io/) and the 95 | [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template) 96 | project template. 97 | -------------------------------------------------------------------------------- /commitlint.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | rules: { 4 | "header-max-length": [0, "always", Infinity], 5 | "body-max-line-length": [0, "always", Infinity], 6 | "footer-max-line-length": [0, "always", Infinity], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | .PHONY: help livehtml Makefile 12 | 13 | # Put it first so that "make" without argument is like "make help". 14 | help: 15 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 16 | 17 | # Build, watch and serve docs with live reload 18 | livehtml: 19 | sphinx-autobuild -b html -c . $(SOURCEDIR) $(BUILDDIR)/html 20 | 21 | # Catch-all target: route all unknown targets to Sphinx using the new 22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 23 | %: Makefile 24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 25 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohappyeyeballs/58183b5e881ad10717ff9f06fce0e418c7d6d71b/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/api_reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. autodata:: aiohappyeyeballs.AddrInfoType 5 | 6 | A tuple representing socket address information. 7 | 8 | Indexes: 9 | 10 | **[0]** ``Union[int, socket.AddressFamily]`` 11 | 12 | The address family, e.g. ``socket.AF_INET`` 13 | 14 | **[1]** ``Union[int, socket.SocketKind]`` 15 | 16 | The type of socket, e.g. ``socket.SOCK_STREAM``. 17 | 18 | **[2]** ``int`` 19 | 20 | The protocol number, e.g. ``socket.IPPROTO_TCP``. 21 | 22 | **[3]** ``str`` 23 | 24 | The canonical name of the address, e.g. ``"www.example.com"``. 25 | 26 | **[4]** ``Tuple`` 27 | 28 | The socket address tuple, e.g. ``("127.0.0.1", 443)``. 29 | 30 | .. autodata:: aiohappyeyeballs.SocketFactoryType 31 | 32 | A callable that creates a socket from an ``AddrInfoType``. 33 | 34 | 35 | :param AddrInfoType: Address info for creating the socket containing 36 | the address family, socket type, protocol, host address, and 37 | additional details. 38 | 39 | :rtype: ``socket.socket`` 40 | 41 | 42 | .. automodule:: aiohappyeyeballs 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | (changelog)= 2 | 3 | ```{include} ../CHANGELOG.md 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # Project information 7 | project = "aiohappyeyeballs" 8 | copyright = "2023, J. Nick Koston" 9 | author = "J. Nick Koston" 10 | release = "2.6.1" 11 | 12 | # General configuration 13 | extensions = [ 14 | "myst_parser", 15 | "sphinx.ext.autodoc", 16 | ] 17 | 18 | # The suffix of source filenames. 19 | source_suffix = [ 20 | ".rst", 21 | ".md", 22 | ] 23 | templates_path = [ 24 | "_templates", 25 | ] 26 | exclude_patterns = [ 27 | "_build", 28 | "Thumbs.db", 29 | ".DS_Store", 30 | ] 31 | 32 | # Options for HTML output 33 | html_theme = "furo" 34 | html_static_path = ["_static"] 35 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | (contributing)= 2 | 3 | ```{include} ../CONTRIBUTING.md 4 | 5 | ``` 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to aiohappyeyeballs documentation! 2 | 3 | ```{toctree} 4 | :caption: Installation & Usage 5 | :maxdepth: 2 6 | 7 | api_reference 8 | installation 9 | usage 10 | ``` 11 | 12 | ```{toctree} 13 | :caption: Project Info 14 | :maxdepth: 2 15 | 16 | changelog 17 | contributing 18 | ``` 19 | 20 | ```{include} ../README.md 21 | 22 | ``` 23 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | (installation)= 2 | 3 | # Installation 4 | 5 | The package is published on [PyPI](https://pypi.org/project/aiohappyeyeballs/) and can be installed with `pip` (or any equivalent): 6 | 7 | ```bash 8 | pip install aiohappyeyeballs 9 | ``` 10 | 11 | Next, see the {ref}`section about usage ` to see how to use it. 12 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | (usage)= 2 | 3 | # Usage 4 | 5 | Assuming that you've followed the {ref}`installations steps `, you're now ready to use this package. 6 | 7 | Start by importing it: 8 | 9 | ```python 10 | import aiohappyeyeballs 11 | 12 | addr_infos = await loop.getaddrinfo("example.org", 80) 13 | 14 | socket = await aiohappyeyeballs.start_connection(addr_infos) 15 | socket = await aiohappyeyeballs.start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2) 16 | 17 | transport, protocol = await loop.create_connection( 18 | MyProtocol, sock=socket, ...) 19 | 20 | # Remove the first address for each family from addr_info 21 | aiohappyeyeballs.pop_addr_infos_interleave(addr_info, 1) 22 | 23 | # Remove all matching address from addr_info 24 | aiohappyeyeballs.remove_addr_infos(addr_info, "dead::beef::") 25 | 26 | # Convert a local_addr to local_addr_infos 27 | local_addr_infos = addr_to_addr_infos(("127.0.0.1",0)) 28 | ``` 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "aiohappyeyeballs" 3 | version = "2.6.1" 4 | description = "Happy Eyeballs for asyncio" 5 | authors = [{ name = "J. Nick Koston", email = "nick@koston.org" }] 6 | readme = "README.md" 7 | requires-python = ">=3.9" 8 | dynamic = ["dependencies", "optional-dependencies"] 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Intended Audience :: Developers", 12 | "Natural Language :: English", 13 | "Operating System :: OS Independent", 14 | "Topic :: Software Development :: Libraries", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "License :: OSI Approved :: Python Software Foundation License" 22 | ] 23 | 24 | [project.urls] 25 | "Repository" = "https://github.com/aio-libs/aiohappyeyeballs" 26 | "Documentation" = "https://aiohappyeyeballs.readthedocs.io" 27 | "Bug Tracker" = "https://github.com/aio-libs/aiohappyeyeballs/issues" 28 | "Changelog" = "https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md" 29 | 30 | [tool.poetry] 31 | license = "PSF-2.0" 32 | packages = [ 33 | { include = "aiohappyeyeballs", from = "src" }, 34 | { include = "tests", format = "sdist" }, 35 | ] 36 | 37 | [tool.poetry.dependencies] 38 | python = ">=3.9" 39 | 40 | [tool.poetry.group.dev.dependencies] 41 | pytest = ">=7,<9" 42 | pytest-cov = ">=3,<7" 43 | pytest-asyncio = ">=0.23.2,<0.27.0" 44 | 45 | [tool.poetry.group.docs] 46 | optional = true 47 | 48 | [tool.poetry.group.docs.dependencies] 49 | myst-parser = ">=0.16" 50 | sphinx = ">=4.0" 51 | furo = ">=2023.5.20" 52 | sphinx-autobuild = ">=2021.3.14" 53 | 54 | 55 | [tool.poetry.group.test_build.dependencies] 56 | twine = ">=4.0.2,<7.0.0" 57 | 58 | [tool.semantic_release] 59 | version_toml = ["pyproject.toml:project.version"] 60 | version_variables = [ 61 | "src/aiohappyeyeballs/__init__.py:__version__", 62 | "docs/conf.py:release", 63 | ] 64 | build_command = "pip install poetry && poetry build" 65 | 66 | [tool.semantic_release.changelog] 67 | exclude_commit_patterns = [ 68 | "chore*", 69 | "ci*", 70 | ] 71 | 72 | [tool.semantic_release.changelog.environment] 73 | keep_trailing_newline = true 74 | 75 | [tool.semantic_release.branches.main] 76 | match = "main" 77 | 78 | [tool.semantic_release.branches.noop] 79 | match = "(?!main$)" 80 | prerelease = true 81 | 82 | [tool.pytest.ini_options] 83 | addopts = "-v -Wdefault --cov=aiohappyeyeballs --cov-report=term-missing:skip-covered" 84 | pythonpath = ["src"] 85 | asyncio_mode = "auto" 86 | asyncio_default_fixture_loop_scope = "function" 87 | 88 | [tool.coverage.run] 89 | branch = true 90 | 91 | [tool.coverage.report] 92 | exclude_lines = [ 93 | "pragma: no cover", 94 | "@overload", 95 | "if TYPE_CHECKING", 96 | "raise NotImplementedError", 97 | 'if __name__ == "__main__":', 98 | ] 99 | 100 | [tool.ruff] 101 | target-version = "py38" 102 | line-length = 88 103 | ignore = [ 104 | "D203", # 1 blank line required before class docstring 105 | "D212", # Multi-line docstring summary should start at the first line 106 | "D100", # Missing docstring in public module 107 | "D104", # Missing docstring in public package 108 | "D107", # Missing docstring in `__init__` 109 | "D401", # First line of docstring should be in imperative mood 110 | ] 111 | select = [ 112 | "B", # flake8-bugbear 113 | "D", # flake8-docstrings 114 | "C4", # flake8-comprehensions 115 | "S", # flake8-bandit 116 | "F", # pyflake 117 | "E", # pycodestyle 118 | "W", # pycodestyle 119 | "UP", # pyupgrade 120 | "I", # isort 121 | "RUF", # ruff specific 122 | ] 123 | 124 | [tool.ruff.per-file-ignores] 125 | "tests/**/*" = [ 126 | "D100", 127 | "D101", 128 | "D102", 129 | "D103", 130 | "D104", 131 | "S101", 132 | ] 133 | "setup.py" = ["D100"] 134 | "conftest.py" = ["D100"] 135 | "docs/conf.py" = ["D100"] 136 | 137 | [tool.ruff.isort] 138 | known-first-party = ["aiohappyeyeballs", "tests"] 139 | 140 | [tool.mypy] 141 | check_untyped_defs = true 142 | disallow_any_generics = true 143 | disallow_incomplete_defs = true 144 | disallow_untyped_defs = true 145 | mypy_path = "src/" 146 | no_implicit_optional = true 147 | show_error_codes = true 148 | warn_unreachable = true 149 | warn_unused_ignores = true 150 | exclude = [ 151 | 'docs/.*', 152 | 'setup.py', 153 | ] 154 | 155 | [[tool.mypy.overrides]] 156 | module = "tests.*" 157 | allow_untyped_defs = true 158 | 159 | [[tool.mypy.overrides]] 160 | module = "docs.*" 161 | ignore_errors = true 162 | 163 | [build-system] 164 | requires = ["poetry-core>=2.0.0"] 165 | build-backend = "poetry.core.masonry.api" 166 | 167 | [tool.codespell] 168 | skip = '*.lock' 169 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>browniebroke/renovate-configs:python"] 3 | } 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This is a shim to allow GitHub to detect the package, build is done with poetry 4 | # Taken from https://github.com/Textualize/rich 5 | 6 | import setuptools 7 | 8 | if __name__ == "__main__": 9 | setuptools.setup(name="aiohappyeyeballs") 10 | -------------------------------------------------------------------------------- /src/aiohappyeyeballs/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.6.1" 2 | 3 | from .impl import start_connection 4 | from .types import AddrInfoType, SocketFactoryType 5 | from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos 6 | 7 | __all__ = ( 8 | "AddrInfoType", 9 | "SocketFactoryType", 10 | "addr_to_addr_infos", 11 | "pop_addr_infos_interleave", 12 | "remove_addr_infos", 13 | "start_connection", 14 | ) 15 | -------------------------------------------------------------------------------- /src/aiohappyeyeballs/_staggered.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | 4 | # PY3.9: Import Callable from typing until we drop Python 3.9 support 5 | # https://github.com/python/cpython/issues/87131 6 | from typing import ( 7 | TYPE_CHECKING, 8 | Any, 9 | Awaitable, 10 | Callable, 11 | Iterable, 12 | List, 13 | Optional, 14 | Set, 15 | Tuple, 16 | TypeVar, 17 | Union, 18 | ) 19 | 20 | _T = TypeVar("_T") 21 | 22 | RE_RAISE_EXCEPTIONS = (SystemExit, KeyboardInterrupt) 23 | 24 | 25 | def _set_result(wait_next: "asyncio.Future[None]") -> None: 26 | """Set the result of a future if it is not already done.""" 27 | if not wait_next.done(): 28 | wait_next.set_result(None) 29 | 30 | 31 | async def _wait_one( 32 | futures: "Iterable[asyncio.Future[Any]]", 33 | loop: asyncio.AbstractEventLoop, 34 | ) -> _T: 35 | """Wait for the first future to complete.""" 36 | wait_next = loop.create_future() 37 | 38 | def _on_completion(fut: "asyncio.Future[Any]") -> None: 39 | if not wait_next.done(): 40 | wait_next.set_result(fut) 41 | 42 | for f in futures: 43 | f.add_done_callback(_on_completion) 44 | 45 | try: 46 | return await wait_next 47 | finally: 48 | for f in futures: 49 | f.remove_done_callback(_on_completion) 50 | 51 | 52 | async def staggered_race( 53 | coro_fns: Iterable[Callable[[], Awaitable[_T]]], 54 | delay: Optional[float], 55 | *, 56 | loop: Optional[asyncio.AbstractEventLoop] = None, 57 | ) -> Tuple[Optional[_T], Optional[int], List[Optional[BaseException]]]: 58 | """ 59 | Run coroutines with staggered start times and take the first to finish. 60 | 61 | This method takes an iterable of coroutine functions. The first one is 62 | started immediately. From then on, whenever the immediately preceding one 63 | fails (raises an exception), or when *delay* seconds has passed, the next 64 | coroutine is started. This continues until one of the coroutines complete 65 | successfully, in which case all others are cancelled, or until all 66 | coroutines fail. 67 | 68 | The coroutines provided should be well-behaved in the following way: 69 | 70 | * They should only ``return`` if completed successfully. 71 | 72 | * They should always raise an exception if they did not complete 73 | successfully. In particular, if they handle cancellation, they should 74 | probably reraise, like this:: 75 | 76 | try: 77 | # do work 78 | except asyncio.CancelledError: 79 | # undo partially completed work 80 | raise 81 | 82 | Args: 83 | ---- 84 | coro_fns: an iterable of coroutine functions, i.e. callables that 85 | return a coroutine object when called. Use ``functools.partial`` or 86 | lambdas to pass arguments. 87 | 88 | delay: amount of time, in seconds, between starting coroutines. If 89 | ``None``, the coroutines will run sequentially. 90 | 91 | loop: the event loop to use. If ``None``, the running loop is used. 92 | 93 | Returns: 94 | ------- 95 | tuple *(winner_result, winner_index, exceptions)* where 96 | 97 | - *winner_result*: the result of the winning coroutine, or ``None`` 98 | if no coroutines won. 99 | 100 | - *winner_index*: the index of the winning coroutine in 101 | ``coro_fns``, or ``None`` if no coroutines won. If the winning 102 | coroutine may return None on success, *winner_index* can be used 103 | to definitively determine whether any coroutine won. 104 | 105 | - *exceptions*: list of exceptions returned by the coroutines. 106 | ``len(exceptions)`` is equal to the number of coroutines actually 107 | started, and the order is the same as in ``coro_fns``. The winning 108 | coroutine's entry is ``None``. 109 | 110 | """ 111 | loop = loop or asyncio.get_running_loop() 112 | exceptions: List[Optional[BaseException]] = [] 113 | tasks: Set[asyncio.Task[Optional[Tuple[_T, int]]]] = set() 114 | 115 | async def run_one_coro( 116 | coro_fn: Callable[[], Awaitable[_T]], 117 | this_index: int, 118 | start_next: "asyncio.Future[None]", 119 | ) -> Optional[Tuple[_T, int]]: 120 | """ 121 | Run a single coroutine. 122 | 123 | If the coroutine fails, set the exception in the exceptions list and 124 | start the next coroutine by setting the result of the start_next. 125 | 126 | If the coroutine succeeds, return the result and the index of the 127 | coroutine in the coro_fns list. 128 | 129 | If SystemExit or KeyboardInterrupt is raised, re-raise it. 130 | """ 131 | try: 132 | result = await coro_fn() 133 | except RE_RAISE_EXCEPTIONS: 134 | raise 135 | except BaseException as e: 136 | exceptions[this_index] = e 137 | _set_result(start_next) # Kickstart the next coroutine 138 | return None 139 | 140 | return result, this_index 141 | 142 | start_next_timer: Optional[asyncio.TimerHandle] = None 143 | start_next: Optional[asyncio.Future[None]] 144 | task: asyncio.Task[Optional[Tuple[_T, int]]] 145 | done: Union[asyncio.Future[None], asyncio.Task[Optional[Tuple[_T, int]]]] 146 | coro_iter = iter(coro_fns) 147 | this_index = -1 148 | try: 149 | while True: 150 | if coro_fn := next(coro_iter, None): 151 | this_index += 1 152 | exceptions.append(None) 153 | start_next = loop.create_future() 154 | task = loop.create_task(run_one_coro(coro_fn, this_index, start_next)) 155 | tasks.add(task) 156 | start_next_timer = ( 157 | loop.call_later(delay, _set_result, start_next) if delay else None 158 | ) 159 | elif not tasks: 160 | # We exhausted the coro_fns list and no tasks are running 161 | # so we have no winner and all coroutines failed. 162 | break 163 | 164 | while tasks or start_next: 165 | done = await _wait_one( 166 | (*tasks, start_next) if start_next else tasks, loop 167 | ) 168 | if done is start_next: 169 | # The current task has failed or the timer has expired 170 | # so we need to start the next task. 171 | start_next = None 172 | if start_next_timer: 173 | start_next_timer.cancel() 174 | start_next_timer = None 175 | 176 | # Break out of the task waiting loop to start the next 177 | # task. 178 | break 179 | 180 | if TYPE_CHECKING: 181 | assert isinstance(done, asyncio.Task) 182 | 183 | tasks.remove(done) 184 | if winner := done.result(): 185 | return *winner, exceptions 186 | finally: 187 | # We either have: 188 | # - a winner 189 | # - all tasks failed 190 | # - a KeyboardInterrupt or SystemExit. 191 | 192 | # 193 | # If the timer is still running, cancel it. 194 | # 195 | if start_next_timer: 196 | start_next_timer.cancel() 197 | 198 | # 199 | # If there are any tasks left, cancel them and than 200 | # wait them so they fill the exceptions list. 201 | # 202 | for task in tasks: 203 | task.cancel() 204 | with contextlib.suppress(asyncio.CancelledError): 205 | await task 206 | 207 | return None, None, exceptions 208 | -------------------------------------------------------------------------------- /src/aiohappyeyeballs/impl.py: -------------------------------------------------------------------------------- 1 | """Base implementation.""" 2 | 3 | import asyncio 4 | import collections 5 | import contextlib 6 | import functools 7 | import itertools 8 | import socket 9 | from typing import List, Optional, Sequence, Set, Union 10 | 11 | from . import _staggered 12 | from .types import AddrInfoType, SocketFactoryType 13 | 14 | 15 | async def start_connection( 16 | addr_infos: Sequence[AddrInfoType], 17 | *, 18 | local_addr_infos: Optional[Sequence[AddrInfoType]] = None, 19 | happy_eyeballs_delay: Optional[float] = None, 20 | interleave: Optional[int] = None, 21 | loop: Optional[asyncio.AbstractEventLoop] = None, 22 | socket_factory: Optional[SocketFactoryType] = None, 23 | ) -> socket.socket: 24 | """ 25 | Connect to a TCP server. 26 | 27 | Create a socket connection to a specified destination. The 28 | destination is specified as a list of AddrInfoType tuples as 29 | returned from getaddrinfo(). 30 | 31 | The arguments are, in order: 32 | 33 | * ``family``: the address family, e.g. ``socket.AF_INET`` or 34 | ``socket.AF_INET6``. 35 | * ``type``: the socket type, e.g. ``socket.SOCK_STREAM`` or 36 | ``socket.SOCK_DGRAM``. 37 | * ``proto``: the protocol, e.g. ``socket.IPPROTO_TCP`` or 38 | ``socket.IPPROTO_UDP``. 39 | * ``canonname``: the canonical name of the address, e.g. 40 | ``"www.python.org"``. 41 | * ``sockaddr``: the socket address 42 | 43 | This method is a coroutine which will try to establish the connection 44 | in the background. When successful, the coroutine returns a 45 | socket. 46 | 47 | The expected use case is to use this method in conjunction with 48 | loop.create_connection() to establish a connection to a server:: 49 | 50 | socket = await start_connection(addr_infos) 51 | transport, protocol = await loop.create_connection( 52 | MyProtocol, sock=socket, ...) 53 | """ 54 | if not (current_loop := loop): 55 | current_loop = asyncio.get_running_loop() 56 | 57 | single_addr_info = len(addr_infos) == 1 58 | 59 | if happy_eyeballs_delay is not None and interleave is None: 60 | # If using happy eyeballs, default to interleave addresses by family 61 | interleave = 1 62 | 63 | if interleave and not single_addr_info: 64 | addr_infos = _interleave_addrinfos(addr_infos, interleave) 65 | 66 | sock: Optional[socket.socket] = None 67 | # uvloop can raise RuntimeError instead of OSError 68 | exceptions: List[List[Union[OSError, RuntimeError]]] = [] 69 | if happy_eyeballs_delay is None or single_addr_info: 70 | # not using happy eyeballs 71 | for addrinfo in addr_infos: 72 | try: 73 | sock = await _connect_sock( 74 | current_loop, 75 | exceptions, 76 | addrinfo, 77 | local_addr_infos, 78 | None, 79 | socket_factory, 80 | ) 81 | break 82 | except (RuntimeError, OSError): 83 | continue 84 | else: # using happy eyeballs 85 | open_sockets: Set[socket.socket] = set() 86 | try: 87 | sock, _, _ = await _staggered.staggered_race( 88 | ( 89 | functools.partial( 90 | _connect_sock, 91 | current_loop, 92 | exceptions, 93 | addrinfo, 94 | local_addr_infos, 95 | open_sockets, 96 | socket_factory, 97 | ) 98 | for addrinfo in addr_infos 99 | ), 100 | happy_eyeballs_delay, 101 | ) 102 | finally: 103 | # If we have a winner, staggered_race will 104 | # cancel the other tasks, however there is a 105 | # small race window where any of the other tasks 106 | # can be done before they are cancelled which 107 | # will leave the socket open. To avoid this problem 108 | # we pass a set to _connect_sock to keep track of 109 | # the open sockets and close them here if there 110 | # are any "runner up" sockets. 111 | for s in open_sockets: 112 | if s is not sock: 113 | with contextlib.suppress(OSError): 114 | s.close() 115 | open_sockets = None # type: ignore[assignment] 116 | 117 | if sock is None: 118 | all_exceptions = [exc for sub in exceptions for exc in sub] 119 | try: 120 | first_exception = all_exceptions[0] 121 | if len(all_exceptions) == 1: 122 | raise first_exception 123 | else: 124 | # If they all have the same str(), raise one. 125 | model = str(first_exception) 126 | if all(str(exc) == model for exc in all_exceptions): 127 | raise first_exception 128 | # Raise a combined exception so the user can see all 129 | # the various error messages. 130 | msg = "Multiple exceptions: {}".format( 131 | ", ".join(str(exc) for exc in all_exceptions) 132 | ) 133 | # If the errno is the same for all exceptions, raise 134 | # an OSError with that errno. 135 | if isinstance(first_exception, OSError): 136 | first_errno = first_exception.errno 137 | if all( 138 | isinstance(exc, OSError) and exc.errno == first_errno 139 | for exc in all_exceptions 140 | ): 141 | raise OSError(first_errno, msg) 142 | elif isinstance(first_exception, RuntimeError) and all( 143 | isinstance(exc, RuntimeError) for exc in all_exceptions 144 | ): 145 | raise RuntimeError(msg) 146 | # We have a mix of OSError and RuntimeError 147 | # so we have to pick which one to raise. 148 | # and we raise OSError for compatibility 149 | raise OSError(msg) 150 | finally: 151 | all_exceptions = None # type: ignore[assignment] 152 | exceptions = None # type: ignore[assignment] 153 | 154 | return sock 155 | 156 | 157 | async def _connect_sock( 158 | loop: asyncio.AbstractEventLoop, 159 | exceptions: List[List[Union[OSError, RuntimeError]]], 160 | addr_info: AddrInfoType, 161 | local_addr_infos: Optional[Sequence[AddrInfoType]] = None, 162 | open_sockets: Optional[Set[socket.socket]] = None, 163 | socket_factory: Optional[SocketFactoryType] = None, 164 | ) -> socket.socket: 165 | """ 166 | Create, bind and connect one socket. 167 | 168 | If open_sockets is passed, add the socket to the set of open sockets. 169 | Any failure caught here will remove the socket from the set and close it. 170 | 171 | Callers can use this set to close any sockets that are not the winner 172 | of all staggered tasks in the result there are runner up sockets aka 173 | multiple winners. 174 | """ 175 | my_exceptions: List[Union[OSError, RuntimeError]] = [] 176 | exceptions.append(my_exceptions) 177 | family, type_, proto, _, address = addr_info 178 | sock = None 179 | try: 180 | if socket_factory is not None: 181 | sock = socket_factory(addr_info) 182 | else: 183 | sock = socket.socket(family=family, type=type_, proto=proto) 184 | if open_sockets is not None: 185 | open_sockets.add(sock) 186 | sock.setblocking(False) 187 | if local_addr_infos is not None: 188 | for lfamily, _, _, _, laddr in local_addr_infos: 189 | # skip local addresses of different family 190 | if lfamily != family: 191 | continue 192 | try: 193 | sock.bind(laddr) 194 | break 195 | except OSError as exc: 196 | msg = ( 197 | f"error while attempting to bind on " 198 | f"address {laddr!r}: " 199 | f"{(exc.strerror or '').lower()}" 200 | ) 201 | exc = OSError(exc.errno, msg) 202 | my_exceptions.append(exc) 203 | else: # all bind attempts failed 204 | if my_exceptions: 205 | raise my_exceptions.pop() 206 | else: 207 | raise OSError(f"no matching local address with {family=} found") 208 | await loop.sock_connect(sock, address) 209 | return sock 210 | except (RuntimeError, OSError) as exc: 211 | my_exceptions.append(exc) 212 | if sock is not None: 213 | if open_sockets is not None: 214 | open_sockets.remove(sock) 215 | try: 216 | sock.close() 217 | except OSError as e: 218 | my_exceptions.append(e) 219 | raise 220 | raise 221 | except: 222 | if sock is not None: 223 | if open_sockets is not None: 224 | open_sockets.remove(sock) 225 | try: 226 | sock.close() 227 | except OSError as e: 228 | my_exceptions.append(e) 229 | raise 230 | raise 231 | finally: 232 | exceptions = my_exceptions = None # type: ignore[assignment] 233 | 234 | 235 | def _interleave_addrinfos( 236 | addrinfos: Sequence[AddrInfoType], first_address_family_count: int = 1 237 | ) -> List[AddrInfoType]: 238 | """Interleave list of addrinfo tuples by family.""" 239 | # Group addresses by family 240 | addrinfos_by_family: collections.OrderedDict[int, List[AddrInfoType]] = ( 241 | collections.OrderedDict() 242 | ) 243 | for addr in addrinfos: 244 | family = addr[0] 245 | if family not in addrinfos_by_family: 246 | addrinfos_by_family[family] = [] 247 | addrinfos_by_family[family].append(addr) 248 | addrinfos_lists = list(addrinfos_by_family.values()) 249 | 250 | reordered: List[AddrInfoType] = [] 251 | if first_address_family_count > 1: 252 | reordered.extend(addrinfos_lists[0][: first_address_family_count - 1]) 253 | del addrinfos_lists[0][: first_address_family_count - 1] 254 | reordered.extend( 255 | a 256 | for a in itertools.chain.from_iterable(itertools.zip_longest(*addrinfos_lists)) 257 | if a is not None 258 | ) 259 | return reordered 260 | -------------------------------------------------------------------------------- /src/aiohappyeyeballs/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohappyeyeballs/58183b5e881ad10717ff9f06fce0e418c7d6d71b/src/aiohappyeyeballs/py.typed -------------------------------------------------------------------------------- /src/aiohappyeyeballs/types.py: -------------------------------------------------------------------------------- 1 | """Types for aiohappyeyeballs.""" 2 | 3 | import socket 4 | 5 | # PY3.9: Import Callable from typing until we drop Python 3.9 support 6 | # https://github.com/python/cpython/issues/87131 7 | from typing import Callable, Tuple, Union 8 | 9 | AddrInfoType = Tuple[ 10 | Union[int, socket.AddressFamily], 11 | Union[int, socket.SocketKind], 12 | int, 13 | str, 14 | Tuple, # type: ignore[type-arg] 15 | ] 16 | 17 | SocketFactoryType = Callable[[AddrInfoType], socket.socket] 18 | -------------------------------------------------------------------------------- /src/aiohappyeyeballs/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for aiohappyeyeballs.""" 2 | 3 | import ipaddress 4 | import socket 5 | from typing import Dict, List, Optional, Tuple, Union 6 | 7 | from .types import AddrInfoType 8 | 9 | 10 | def addr_to_addr_infos( 11 | addr: Optional[ 12 | Union[Tuple[str, int, int, int], Tuple[str, int, int], Tuple[str, int]] 13 | ], 14 | ) -> Optional[List[AddrInfoType]]: 15 | """Convert an address tuple to a list of addr_info tuples.""" 16 | if addr is None: 17 | return None 18 | host = addr[0] 19 | port = addr[1] 20 | is_ipv6 = ":" in host 21 | if is_ipv6: 22 | flowinfo = 0 23 | scopeid = 0 24 | addr_len = len(addr) 25 | if addr_len >= 4: 26 | scopeid = addr[3] # type: ignore[misc] 27 | if addr_len >= 3: 28 | flowinfo = addr[2] # type: ignore[misc] 29 | addr = (host, port, flowinfo, scopeid) 30 | family = socket.AF_INET6 31 | else: 32 | addr = (host, port) 33 | family = socket.AF_INET 34 | return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", addr)] 35 | 36 | 37 | def pop_addr_infos_interleave( 38 | addr_infos: List[AddrInfoType], interleave: Optional[int] = None 39 | ) -> None: 40 | """ 41 | Pop addr_info from the list of addr_infos by family up to interleave times. 42 | 43 | The interleave parameter is used to know how many addr_infos for 44 | each family should be popped of the top of the list. 45 | """ 46 | seen: Dict[int, int] = {} 47 | if interleave is None: 48 | interleave = 1 49 | to_remove: List[AddrInfoType] = [] 50 | for addr_info in addr_infos: 51 | family = addr_info[0] 52 | if family not in seen: 53 | seen[family] = 0 54 | if seen[family] < interleave: 55 | to_remove.append(addr_info) 56 | seen[family] += 1 57 | for addr_info in to_remove: 58 | addr_infos.remove(addr_info) 59 | 60 | 61 | def _addr_tuple_to_ip_address( 62 | addr: Union[Tuple[str, int], Tuple[str, int, int, int]], 63 | ) -> Union[ 64 | Tuple[ipaddress.IPv4Address, int], Tuple[ipaddress.IPv6Address, int, int, int] 65 | ]: 66 | """Convert an address tuple to an IPv4Address.""" 67 | return (ipaddress.ip_address(addr[0]), *addr[1:]) 68 | 69 | 70 | def remove_addr_infos( 71 | addr_infos: List[AddrInfoType], 72 | addr: Union[Tuple[str, int], Tuple[str, int, int, int]], 73 | ) -> None: 74 | """ 75 | Remove an address from the list of addr_infos. 76 | 77 | The addr value is typically the return value of 78 | sock.getpeername(). 79 | """ 80 | bad_addrs_infos: List[AddrInfoType] = [] 81 | for addr_info in addr_infos: 82 | if addr_info[-1] == addr: 83 | bad_addrs_infos.append(addr_info) 84 | if bad_addrs_infos: 85 | for bad_addr_info in bad_addrs_infos: 86 | addr_infos.remove(bad_addr_info) 87 | return 88 | # Slow path in case addr is formatted differently 89 | match_addr = _addr_tuple_to_ip_address(addr) 90 | for addr_info in addr_infos: 91 | if match_addr == _addr_tuple_to_ip_address(addr_info[-1]): 92 | bad_addrs_infos.append(addr_info) 93 | if bad_addrs_infos: 94 | for bad_addr_info in bad_addrs_infos: 95 | addr_infos.remove(bad_addr_info) 96 | return 97 | raise ValueError(f"Address {addr} not found in addr_infos") 98 | -------------------------------------------------------------------------------- /templates/CHANGELOG.md.j2: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | {%- for version, release in context.history.released.items() %} 4 | 5 | ## {{ version.as_tag() }} ({{ release.tagged_date.strftime("%Y-%m-%d") }}) 6 | 7 | {%- for category, commits in release["elements"].items() %} 8 | {# Category title: Breaking, Fix, Documentation #} 9 | ### {{ category | capitalize }} 10 | {# List actual changes in the category #} 11 | {%- for commit in commits %} 12 | - {{ commit.descriptions[0] | capitalize }} ([`{{ commit.short_hash }}`]({{ commit.hexsha | commit_hash_url }})) 13 | {%- endfor %}{# for commit #} 14 | 15 | {%- endfor %}{# for category, commits #} 16 | 17 | {%- endfor %}{# for version, release #} 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiohappyeyeballs/58183b5e881ad10717ff9f06fce0e418c7d6d71b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration for the tests.""" 2 | 3 | import asyncio 4 | import reprlib 5 | import threading 6 | from asyncio.events import AbstractEventLoop, TimerHandle 7 | from contextlib import contextmanager 8 | from typing import Generator 9 | 10 | import pytest 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def verify_threads_ended() -> Generator[None, None, None]: 15 | """Verify that the threads are not running after the test.""" 16 | threads_before = frozenset(threading.enumerate()) 17 | yield 18 | threads = frozenset(threading.enumerate()) - threads_before 19 | assert not threads 20 | 21 | 22 | def get_scheduled_timer_handles(loop: AbstractEventLoop) -> list[TimerHandle]: 23 | """Return a list of scheduled TimerHandles.""" 24 | handles: list[TimerHandle] = loop._scheduled # type: ignore[attr-defined] 25 | return handles 26 | 27 | 28 | @contextmanager 29 | def long_repr_strings() -> Generator[None, None, None]: 30 | """Increase reprlib maxstring and maxother to 300.""" 31 | arepr = reprlib.aRepr 32 | original_maxstring = arepr.maxstring 33 | original_maxother = arepr.maxother 34 | arepr.maxstring = 300 35 | arepr.maxother = 300 36 | try: 37 | yield 38 | finally: 39 | arepr.maxstring = original_maxstring 40 | arepr.maxother = original_maxother 41 | 42 | 43 | @pytest.fixture(autouse=True) 44 | def verify_no_lingering_tasks( 45 | event_loop: asyncio.AbstractEventLoop, 46 | ) -> Generator[None, None, None]: 47 | """Verify that all tasks are cleaned up.""" 48 | tasks_before = asyncio.all_tasks(event_loop) 49 | yield 50 | 51 | tasks = asyncio.all_tasks(event_loop) - tasks_before 52 | for task in tasks: 53 | pytest.fail(f"Task still running: {task!r}") 54 | task.cancel() 55 | if tasks: 56 | event_loop.run_until_complete(asyncio.wait(tasks)) 57 | 58 | for handle in get_scheduled_timer_handles(event_loop): 59 | if not handle.cancelled(): 60 | with long_repr_strings(): 61 | pytest.fail(f"Lingering timer after test {handle!r}") 62 | handle.cancel() 63 | -------------------------------------------------------------------------------- /tests/test_impl.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | from types import ModuleType 4 | from typing import List, Optional, Sequence, Set, Tuple, Union 5 | from unittest import mock 6 | 7 | import pytest 8 | 9 | from aiohappyeyeballs import ( 10 | AddrInfoType, 11 | SocketFactoryType, 12 | _staggered, 13 | impl, 14 | start_connection, 15 | ) 16 | 17 | 18 | def mock_socket_module(): 19 | m_socket = mock.MagicMock(spec=socket) 20 | for name in ( 21 | "AF_INET", 22 | "AF_INET6", 23 | "AF_UNSPEC", 24 | "IPPROTO_TCP", 25 | "IPPROTO_UDP", 26 | "SOCK_STREAM", 27 | "SOCK_DGRAM", 28 | "SOL_SOCKET", 29 | "SO_REUSEADDR", 30 | "inet_pton", 31 | ): 32 | if hasattr(socket, name): 33 | setattr(m_socket, name, getattr(socket, name)) 34 | else: 35 | delattr(m_socket, name) 36 | 37 | m_socket.socket = mock.MagicMock() 38 | m_socket.socket.return_value = mock_nonblocking_socket() 39 | 40 | return m_socket 41 | 42 | 43 | def mock_nonblocking_socket( 44 | proto=socket.IPPROTO_TCP, type=socket.SOCK_STREAM, family=socket.AF_INET 45 | ): 46 | """Create a mock of a non-blocking socket.""" 47 | sock = mock.create_autospec(socket.socket, spec_set=True, instance=True) 48 | sock.proto = proto 49 | sock.type = type 50 | sock.family = family 51 | sock.gettimeout.return_value = 0.0 52 | return sock 53 | 54 | 55 | def patch_socket(f): 56 | return mock.patch("aiohappyeyeballs.impl.socket", new_callable=mock_socket_module)( 57 | f 58 | ) 59 | 60 | 61 | @pytest.mark.asyncio 62 | @patch_socket 63 | async def test_single_addr_info_errors(m_socket: ModuleType) -> None: 64 | idx = -1 65 | errors = ["err1", "err2"] 66 | 67 | def _socket(*args, **kw): 68 | nonlocal idx, errors 69 | idx += 1 70 | raise OSError(5, errors[idx]) 71 | 72 | m_socket.socket = _socket # type: ignore 73 | addr_info = [ 74 | ( 75 | socket.AF_INET, 76 | socket.SOCK_STREAM, 77 | socket.IPPROTO_TCP, 78 | "", 79 | ("107.6.106.82", 80), 80 | ) 81 | ] 82 | with pytest.raises(OSError, match=errors[0]): 83 | await start_connection(addr_info) 84 | 85 | 86 | @pytest.mark.asyncio 87 | @patch_socket 88 | async def test_single_addr_success(m_socket: ModuleType) -> None: 89 | mock_socket = mock.MagicMock( 90 | family=socket.AF_INET, 91 | type=socket.SOCK_STREAM, 92 | proto=socket.IPPROTO_TCP, 93 | fileno=mock.MagicMock(return_value=1), 94 | ) 95 | 96 | def _socket(*args, **kw): 97 | return mock_socket 98 | 99 | m_socket.socket = _socket # type: ignore 100 | addr_info = [ 101 | ( 102 | socket.AF_INET, 103 | socket.SOCK_STREAM, 104 | socket.IPPROTO_TCP, 105 | "", 106 | ("107.6.106.82", 80), 107 | ) 108 | ] 109 | loop = asyncio.get_running_loop() 110 | with mock.patch.object(loop, "sock_connect", return_value=None): 111 | assert await start_connection(addr_info) == mock_socket 112 | 113 | 114 | @pytest.mark.asyncio 115 | @patch_socket 116 | async def test_single_addr_success_passing_loop(m_socket: ModuleType) -> None: 117 | mock_socket = mock.MagicMock( 118 | family=socket.AF_INET, 119 | type=socket.SOCK_STREAM, 120 | proto=socket.IPPROTO_TCP, 121 | fileno=mock.MagicMock(return_value=1), 122 | ) 123 | 124 | def _socket(*args, **kw): 125 | return mock_socket 126 | 127 | m_socket.socket = _socket # type: ignore 128 | addr_info = [ 129 | ( 130 | socket.AF_INET, 131 | socket.SOCK_STREAM, 132 | socket.IPPROTO_TCP, 133 | "", 134 | ("107.6.106.82", 80), 135 | ) 136 | ] 137 | loop = asyncio.get_running_loop() 138 | with mock.patch.object(loop, "sock_connect", return_value=None): 139 | assert ( 140 | await start_connection(addr_info, loop=asyncio.get_running_loop()) 141 | == mock_socket 142 | ) 143 | 144 | 145 | @pytest.mark.asyncio 146 | @patch_socket 147 | async def test_single_addr_socket_factory(m_socket: ModuleType) -> None: 148 | mock_socket = mock.MagicMock( 149 | family=socket.AF_INET, 150 | type=socket.SOCK_STREAM, 151 | proto=socket.IPPROTO_TCP, 152 | fileno=mock.MagicMock(return_value=1), 153 | ) 154 | 155 | def factory(addr_info: AddrInfoType) -> socket.socket: 156 | return mock_socket 157 | 158 | addr_info = [ 159 | ( 160 | socket.AF_INET, 161 | socket.SOCK_STREAM, 162 | socket.IPPROTO_TCP, 163 | "", 164 | ("107.6.106.82", 80), 165 | ) 166 | ] 167 | loop = asyncio.get_running_loop() 168 | with mock.patch.object(loop, "sock_connect", return_value=None): 169 | assert await start_connection(addr_info, socket_factory=factory) == mock_socket 170 | 171 | 172 | @pytest.mark.asyncio 173 | @patch_socket 174 | async def test_multiple_addr_success_second_one( 175 | m_socket: ModuleType, 176 | ) -> None: 177 | mock_socket = mock.MagicMock( 178 | family=socket.AF_INET, 179 | type=socket.SOCK_STREAM, 180 | proto=socket.IPPROTO_TCP, 181 | fileno=mock.MagicMock(return_value=1), 182 | ) 183 | idx = -1 184 | errors = ["err1", "err2"] 185 | 186 | def _socket(*args, **kw): 187 | nonlocal idx, errors 188 | idx += 1 189 | if idx == 1: 190 | raise OSError(5, errors[idx]) 191 | return mock_socket 192 | 193 | m_socket.socket = _socket # type: ignore 194 | addr_info = [ 195 | ( 196 | socket.AF_INET, 197 | socket.SOCK_STREAM, 198 | socket.IPPROTO_TCP, 199 | "", 200 | ("107.6.106.82", 80), 201 | ), 202 | ( 203 | socket.AF_INET, 204 | socket.SOCK_STREAM, 205 | socket.IPPROTO_TCP, 206 | "", 207 | ("107.6.106.83", 80), 208 | ), 209 | ] 210 | loop = asyncio.get_running_loop() 211 | with mock.patch.object(loop, "sock_connect", return_value=None): 212 | assert await start_connection(addr_info) == mock_socket 213 | 214 | 215 | @pytest.mark.asyncio 216 | @patch_socket 217 | async def test_multiple_winners_cleaned_up( 218 | m_socket: ModuleType, 219 | ) -> None: 220 | loop = asyncio.get_running_loop() 221 | finish = loop.create_future() 222 | 223 | def _socket(*args, **kw): 224 | return mock.MagicMock( 225 | family=socket.AF_INET, 226 | type=socket.SOCK_STREAM, 227 | proto=socket.IPPROTO_TCP, 228 | fileno=mock.MagicMock(return_value=1), 229 | ) 230 | 231 | async def _connect_sock( 232 | loop: asyncio.AbstractEventLoop, 233 | exceptions: List[List[Union[OSError, RuntimeError]]], 234 | addr_info: AddrInfoType, 235 | local_addr_infos: Optional[Sequence[AddrInfoType]] = None, 236 | sockets: Optional[Set[socket.socket]] = None, 237 | socket_factory: Optional[SocketFactoryType] = None, 238 | ) -> socket.socket: 239 | await finish 240 | sock = _socket() 241 | assert sockets is not None 242 | sockets.add(sock) 243 | return sock 244 | 245 | m_socket.socket = _socket # type: ignore 246 | addr_info = [ 247 | ( 248 | socket.AF_INET, 249 | socket.SOCK_STREAM, 250 | socket.IPPROTO_TCP, 251 | "", 252 | ("107.6.106.82", 80), 253 | ), 254 | ( 255 | socket.AF_INET, 256 | socket.SOCK_STREAM, 257 | socket.IPPROTO_TCP, 258 | "", 259 | ("107.6.106.83", 80), 260 | ), 261 | ( 262 | socket.AF_INET, 263 | socket.SOCK_STREAM, 264 | socket.IPPROTO_TCP, 265 | "", 266 | ("107.6.106.84", 80), 267 | ), 268 | ( 269 | socket.AF_INET, 270 | socket.SOCK_STREAM, 271 | socket.IPPROTO_TCP, 272 | "", 273 | ("107.6.106.85", 80), 274 | ), 275 | ] 276 | with mock.patch.object(impl, "_connect_sock", _connect_sock): 277 | task = loop.create_task( 278 | start_connection(addr_info, happy_eyeballs_delay=0.0001, interleave=0) 279 | ) 280 | await asyncio.sleep(0.1) 281 | loop.call_soon(finish.set_result, None) 282 | await task 283 | 284 | 285 | @pytest.mark.asyncio 286 | @patch_socket 287 | async def test_multiple_addr_success_second_one_happy_eyeballs( 288 | m_socket: ModuleType, 289 | ) -> None: 290 | mock_socket = mock.MagicMock( 291 | family=socket.AF_INET, 292 | type=socket.SOCK_STREAM, 293 | proto=socket.IPPROTO_TCP, 294 | fileno=mock.MagicMock(return_value=1), 295 | ) 296 | idx = -1 297 | errors = ["err1", "err2"] 298 | 299 | def _socket(*args, **kw): 300 | nonlocal idx, errors 301 | idx += 1 302 | if idx == 1: 303 | raise OSError(5, errors[idx]) 304 | return mock_socket 305 | 306 | m_socket.socket = _socket # type: ignore 307 | addr_info = [ 308 | ( 309 | socket.AF_INET, 310 | socket.SOCK_STREAM, 311 | socket.IPPROTO_TCP, 312 | "", 313 | ("107.6.106.82", 80), 314 | ), 315 | ( 316 | socket.AF_INET, 317 | socket.SOCK_STREAM, 318 | socket.IPPROTO_TCP, 319 | "", 320 | ("107.6.106.83", 80), 321 | ), 322 | ] 323 | loop = asyncio.get_running_loop() 324 | with mock.patch.object(loop, "sock_connect", return_value=None): 325 | assert ( 326 | await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket 327 | ) 328 | 329 | 330 | @pytest.mark.asyncio 331 | @patch_socket 332 | async def test_happy_eyeballs_socket_factory( 333 | m_socket: ModuleType, 334 | ) -> None: 335 | mock_socket = mock.MagicMock( 336 | family=socket.AF_INET, 337 | type=socket.SOCK_STREAM, 338 | proto=socket.IPPROTO_TCP, 339 | fileno=mock.MagicMock(return_value=1), 340 | ) 341 | 342 | idx = -1 343 | errors = ["err1", "err2"] 344 | 345 | def factory(addr_info: AddrInfoType) -> socket.socket: 346 | nonlocal idx, errors 347 | idx += 1 348 | if idx == 1: 349 | raise OSError(5, errors[idx]) 350 | return mock_socket 351 | 352 | addr_info = [ 353 | ( 354 | socket.AF_INET, 355 | socket.SOCK_STREAM, 356 | socket.IPPROTO_TCP, 357 | "", 358 | ("107.6.106.82", 80), 359 | ), 360 | ( 361 | socket.AF_INET, 362 | socket.SOCK_STREAM, 363 | socket.IPPROTO_TCP, 364 | "", 365 | ("107.6.106.83", 80), 366 | ), 367 | ] 368 | loop = asyncio.get_running_loop() 369 | with mock.patch.object(loop, "sock_connect", return_value=None): 370 | assert ( 371 | await start_connection( 372 | addr_info, happy_eyeballs_delay=0.3, socket_factory=factory 373 | ) 374 | == mock_socket 375 | ) 376 | 377 | 378 | @pytest.mark.asyncio 379 | @patch_socket 380 | async def test_multiple_addr_all_fail_happy_eyeballs( 381 | m_socket: ModuleType, 382 | ) -> None: 383 | mock.MagicMock( 384 | family=socket.AF_INET, 385 | type=socket.SOCK_STREAM, 386 | proto=socket.IPPROTO_TCP, 387 | fileno=mock.MagicMock(return_value=1), 388 | ) 389 | idx = -1 390 | errors = ["err1", "err2"] 391 | 392 | def _socket(*args, **kw): 393 | nonlocal idx, errors 394 | idx += 1 395 | raise OSError(5, errors[idx]) 396 | 397 | m_socket.socket = _socket # type: ignore 398 | addr_info = [ 399 | ( 400 | socket.AF_INET, 401 | socket.SOCK_STREAM, 402 | socket.IPPROTO_TCP, 403 | "", 404 | ("107.6.106.82", 80), 405 | ), 406 | ( 407 | socket.AF_INET, 408 | socket.SOCK_STREAM, 409 | socket.IPPROTO_TCP, 410 | "", 411 | ("107.6.106.83", 80), 412 | ), 413 | ] 414 | asyncio.get_running_loop() 415 | with pytest.raises(OSError, match=errors[0]): 416 | await start_connection(addr_info, happy_eyeballs_delay=0.3) 417 | 418 | 419 | @pytest.mark.asyncio 420 | @patch_socket 421 | async def test_ipv6_and_ipv4_happy_eyeballs_ipv6_fails( 422 | m_socket: ModuleType, 423 | ) -> None: 424 | mock_socket = mock.MagicMock( 425 | family=socket.AF_INET, 426 | type=socket.SOCK_STREAM, 427 | proto=socket.IPPROTO_TCP, 428 | fileno=mock.MagicMock(return_value=1), 429 | ) 430 | 431 | def _socket(*args, **kw): 432 | if kw["family"] == socket.AF_INET6: 433 | raise OSError(5, "ipv6 fail") 434 | for attr in kw: 435 | setattr(mock_socket, attr, kw[attr]) 436 | return mock_socket 437 | 438 | m_socket.socket = _socket # type: ignore 439 | ipv6_addr_info = ( 440 | socket.AF_INET6, 441 | socket.SOCK_STREAM, 442 | socket.IPPROTO_TCP, 443 | "", 444 | ("dead:beef::", 80, 0, 0), 445 | ) 446 | ipv4_addr_info = ( 447 | socket.AF_INET, 448 | socket.SOCK_STREAM, 449 | socket.IPPROTO_TCP, 450 | "", 451 | ("107.6.106.83", 80), 452 | ) 453 | addr_info = [ipv6_addr_info, ipv4_addr_info] 454 | loop = asyncio.get_running_loop() 455 | with mock.patch.object(loop, "sock_connect", return_value=None): 456 | assert ( 457 | await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket 458 | ) 459 | assert mock_socket.family == socket.AF_INET 460 | 461 | 462 | @pytest.mark.asyncio 463 | @patch_socket 464 | async def test_ipv6_and_ipv4_happy_eyeballs_ipv4_fails( 465 | m_socket: ModuleType, 466 | ) -> None: 467 | mock_socket = mock.MagicMock( 468 | family=socket.AF_INET, 469 | type=socket.SOCK_STREAM, 470 | proto=socket.IPPROTO_TCP, 471 | fileno=mock.MagicMock(return_value=1), 472 | ) 473 | 474 | def _socket(*args, **kw): 475 | if kw["family"] == socket.AF_INET: 476 | raise OSError(5, "ipv4 fail") 477 | for attr in kw: 478 | setattr(mock_socket, attr, kw[attr]) 479 | return mock_socket 480 | 481 | m_socket.socket = _socket # type: ignore 482 | ipv6_addr: Tuple[str, int, int, int] = ("dead:beef::", 80, 0, 0) 483 | ipv6_addr_info: Tuple[int, int, int, str, Tuple[str, int, int, int]] = ( 484 | socket.AF_INET6, 485 | socket.SOCK_STREAM, 486 | socket.IPPROTO_TCP, 487 | "", 488 | ipv6_addr, 489 | ) 490 | ipv4_addr: Tuple[str, int] = ("107.6.106.83", 80) 491 | ipv4_addr_info: Tuple[int, int, int, str, Tuple[str, int]] = ( 492 | socket.AF_INET, 493 | socket.SOCK_STREAM, 494 | socket.IPPROTO_TCP, 495 | "", 496 | ipv4_addr, 497 | ) 498 | addr_info = [ipv6_addr_info, ipv4_addr_info] 499 | loop = asyncio.get_running_loop() 500 | with mock.patch.object(loop, "sock_connect", return_value=None): 501 | assert ( 502 | await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket 503 | ) 504 | assert mock_socket.family == socket.AF_INET6 505 | 506 | 507 | @pytest.mark.asyncio 508 | @patch_socket 509 | async def test_ipv6_and_ipv4_happy_eyeballs_first_ipv6_fails( 510 | m_socket: ModuleType, 511 | ) -> None: 512 | mock_socket = mock.MagicMock( 513 | family=socket.AF_INET, 514 | type=socket.SOCK_STREAM, 515 | proto=socket.IPPROTO_TCP, 516 | fileno=mock.MagicMock(return_value=1), 517 | ) 518 | create_calls = [] 519 | 520 | def _socket(*args, **kw): 521 | for attr in kw: 522 | setattr(mock_socket, attr, kw[attr]) 523 | return mock_socket 524 | 525 | async def _sock_connect( 526 | sock: socket.socket, address: Tuple[str, int, int, int] 527 | ) -> None: 528 | create_calls.append(address) 529 | if address[0] == "dead:beef::": 530 | raise OSError(5, "ipv6 fail") 531 | 532 | return None 533 | 534 | m_socket.socket = _socket # type: ignore 535 | ipv6_addr_info = ( 536 | socket.AF_INET6, 537 | socket.SOCK_STREAM, 538 | socket.IPPROTO_TCP, 539 | "", 540 | ("dead:beef::", 80, 0, 0), 541 | ) 542 | ipv6_addr_info_2 = ( 543 | socket.AF_INET6, 544 | socket.SOCK_STREAM, 545 | socket.IPPROTO_TCP, 546 | "", 547 | ("dead:aaaa::", 80, 0, 0), 548 | ) 549 | ipv4_addr_info = ( 550 | socket.AF_INET, 551 | socket.SOCK_STREAM, 552 | socket.IPPROTO_TCP, 553 | "", 554 | ("107.6.106.83", 80), 555 | ) 556 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 557 | loop = asyncio.get_running_loop() 558 | with mock.patch.object(loop, "sock_connect", _sock_connect): 559 | assert ( 560 | await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket 561 | ) 562 | 563 | # IPv6 addresses are tried first, but the first one fails so IPv4 wins 564 | assert mock_socket.family == socket.AF_INET 565 | assert create_calls == [("dead:beef::", 80, 0, 0), ("107.6.106.83", 80)] 566 | 567 | 568 | @pytest.mark.asyncio 569 | @patch_socket 570 | async def test_ipv64_happy_eyeballs_interleave_2_first_ipv6_fails( 571 | m_socket: ModuleType, 572 | ) -> None: 573 | mock_socket = mock.MagicMock( 574 | family=socket.AF_INET, 575 | type=socket.SOCK_STREAM, 576 | proto=socket.IPPROTO_TCP, 577 | fileno=mock.MagicMock(return_value=1), 578 | ) 579 | create_calls = [] 580 | 581 | def _socket(*args, **kw): 582 | for attr in kw: 583 | setattr(mock_socket, attr, kw[attr]) 584 | return mock_socket 585 | 586 | async def _sock_connect( 587 | sock: socket.socket, address: Tuple[str, int, int, int] 588 | ) -> None: 589 | create_calls.append(address) 590 | if address[0] == "dead:beef::": 591 | raise OSError(5, "ipv6 fail") 592 | 593 | return None 594 | 595 | m_socket.socket = _socket # type: ignore 596 | ipv6_addr_info = ( 597 | socket.AF_INET6, 598 | socket.SOCK_STREAM, 599 | socket.IPPROTO_TCP, 600 | "", 601 | ("dead:beef::", 80, 0, 0), 602 | ) 603 | ipv6_addr_info_2 = ( 604 | socket.AF_INET6, 605 | socket.SOCK_STREAM, 606 | socket.IPPROTO_TCP, 607 | "", 608 | ("dead:aaaa::", 80, 0, 0), 609 | ) 610 | ipv4_addr_info = ( 611 | socket.AF_INET, 612 | socket.SOCK_STREAM, 613 | socket.IPPROTO_TCP, 614 | "", 615 | ("107.6.106.83", 80), 616 | ) 617 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 618 | loop = asyncio.get_running_loop() 619 | with mock.patch.object(loop, "sock_connect", _sock_connect): 620 | assert ( 621 | await start_connection(addr_info, happy_eyeballs_delay=0.3, interleave=2) 622 | == mock_socket 623 | ) 624 | 625 | # IPv6 addresses are tried first, but the first one fails so second IPv6 wins 626 | # because interleave is 2 627 | assert mock_socket.family == socket.AF_INET6 628 | assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)] 629 | 630 | 631 | @pytest.mark.asyncio 632 | @patch_socket 633 | async def test_ipv6_only_happy_eyeballs_first_ipv6_fails( 634 | m_socket: ModuleType, 635 | ) -> None: 636 | mock_socket = mock.MagicMock( 637 | family=socket.AF_INET, 638 | type=socket.SOCK_STREAM, 639 | proto=socket.IPPROTO_TCP, 640 | fileno=mock.MagicMock(return_value=1), 641 | ) 642 | create_calls = [] 643 | 644 | def _socket(*args, **kw): 645 | for attr in kw: 646 | setattr(mock_socket, attr, kw[attr]) 647 | return mock_socket 648 | 649 | async def _sock_connect( 650 | sock: socket.socket, address: Tuple[str, int, int, int] 651 | ) -> None: 652 | create_calls.append(address) 653 | if address[0] == "dead:beef::": 654 | raise OSError(5, "ipv6 fail") 655 | 656 | return None 657 | 658 | m_socket.socket = _socket # type: ignore 659 | ipv6_addr_info = ( 660 | socket.AF_INET6, 661 | socket.SOCK_STREAM, 662 | socket.IPPROTO_TCP, 663 | "", 664 | ("dead:beef::", 80, 0, 0), 665 | ) 666 | ipv6_addr_info_2 = ( 667 | socket.AF_INET6, 668 | socket.SOCK_STREAM, 669 | socket.IPPROTO_TCP, 670 | "", 671 | ("dead:aaaa::", 80, 0, 0), 672 | ) 673 | addr_info = [ipv6_addr_info, ipv6_addr_info_2] 674 | loop = asyncio.get_running_loop() 675 | with mock.patch.object(loop, "sock_connect", _sock_connect): 676 | assert ( 677 | await start_connection(addr_info, happy_eyeballs_delay=0.3) == mock_socket 678 | ) 679 | 680 | # IPv6 address are tried first, but the first one fails so second IPv6 wins 681 | assert mock_socket.family == socket.AF_INET6 682 | assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)] 683 | 684 | 685 | @pytest.mark.asyncio 686 | @patch_socket 687 | async def test_ipv64_laddr_eyeballs_interleave_2_first_ipv6_fails( 688 | m_socket: ModuleType, 689 | ) -> None: 690 | mock_socket = mock.MagicMock( 691 | family=socket.AF_INET, 692 | type=socket.SOCK_STREAM, 693 | proto=socket.IPPROTO_TCP, 694 | fileno=mock.MagicMock(return_value=1), 695 | ) 696 | create_calls = [] 697 | 698 | def _socket(*args, **kw): 699 | for attr in kw: 700 | setattr(mock_socket, attr, kw[attr]) 701 | return mock_socket 702 | 703 | async def _sock_connect( 704 | sock: socket.socket, address: Tuple[str, int, int, int] 705 | ) -> None: 706 | create_calls.append(address) 707 | if address[0] == "dead:beef::": 708 | raise OSError(5, "ipv6 fail") 709 | 710 | return None 711 | 712 | m_socket.socket = _socket # type: ignore 713 | ipv6_addr_info = ( 714 | socket.AF_INET6, 715 | socket.SOCK_STREAM, 716 | socket.IPPROTO_TCP, 717 | "", 718 | ("dead:beef::", 80, 0, 0), 719 | ) 720 | ipv6_addr_info_2 = ( 721 | socket.AF_INET6, 722 | socket.SOCK_STREAM, 723 | socket.IPPROTO_TCP, 724 | "", 725 | ("dead:aaaa::", 80, 0, 0), 726 | ) 727 | ipv4_addr_info = ( 728 | socket.AF_INET, 729 | socket.SOCK_STREAM, 730 | socket.IPPROTO_TCP, 731 | "", 732 | ("107.6.106.83", 80), 733 | ) 734 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 735 | local_addr_infos = [ 736 | ( 737 | socket.AF_INET6, 738 | socket.SOCK_STREAM, 739 | socket.IPPROTO_TCP, 740 | "", 741 | ("::1", 0, 0, 0), 742 | ) 743 | ] 744 | loop = asyncio.get_running_loop() 745 | with mock.patch.object(loop, "sock_connect", _sock_connect): 746 | assert ( 747 | await start_connection( 748 | addr_info, 749 | happy_eyeballs_delay=0.3, 750 | interleave=2, 751 | local_addr_infos=local_addr_infos, 752 | ) 753 | == mock_socket 754 | ) 755 | 756 | # IPv6 addresses are tried first, but the first one fails so second IPv6 wins 757 | # because interleave is 2 758 | assert mock_socket.family == socket.AF_INET6 759 | assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)] 760 | 761 | 762 | @pytest.mark.asyncio 763 | @patch_socket 764 | async def test_ipv64_laddr_both__eyeballs_first_ipv6_fails( 765 | m_socket: ModuleType, 766 | ) -> None: 767 | mock_socket = mock.MagicMock( 768 | family=socket.AF_INET, 769 | type=socket.SOCK_STREAM, 770 | proto=socket.IPPROTO_TCP, 771 | fileno=mock.MagicMock(return_value=1), 772 | ) 773 | create_calls = [] 774 | 775 | def _socket(*args, **kw): 776 | for attr in kw: 777 | setattr(mock_socket, attr, kw[attr]) 778 | return mock_socket 779 | 780 | async def _sock_connect( 781 | sock: socket.socket, address: Tuple[str, int, int, int] 782 | ) -> None: 783 | create_calls.append(address) 784 | if address[0] == "dead:beef::": 785 | raise OSError(5, "ipv6 fail") 786 | 787 | return None 788 | 789 | m_socket.socket = _socket # type: ignore 790 | ipv6_addr_info = ( 791 | socket.AF_INET6, 792 | socket.SOCK_STREAM, 793 | socket.IPPROTO_TCP, 794 | "", 795 | ("dead:beef::", 80, 0, 0), 796 | ) 797 | ipv6_addr_info_2 = ( 798 | socket.AF_INET6, 799 | socket.SOCK_STREAM, 800 | socket.IPPROTO_TCP, 801 | "", 802 | ("dead:aaaa::", 80, 0, 0), 803 | ) 804 | ipv4_addr_info = ( 805 | socket.AF_INET, 806 | socket.SOCK_STREAM, 807 | socket.IPPROTO_TCP, 808 | "", 809 | ("107.6.106.83", 80), 810 | ) 811 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 812 | local_addr_infos = [ 813 | ( 814 | socket.AF_INET6, 815 | socket.SOCK_STREAM, 816 | socket.IPPROTO_TCP, 817 | "", 818 | ("::1", 0, 0, 0), 819 | ), 820 | ( 821 | socket.AF_INET, 822 | socket.SOCK_STREAM, 823 | socket.IPPROTO_TCP, 824 | "", 825 | ("127.0.0.1", 0), 826 | ), 827 | ] 828 | loop = asyncio.get_running_loop() 829 | with mock.patch.object(loop, "sock_connect", _sock_connect): 830 | assert ( 831 | await start_connection( 832 | addr_info, 833 | happy_eyeballs_delay=0.3, 834 | local_addr_infos=local_addr_infos, 835 | ) 836 | == mock_socket 837 | ) 838 | 839 | # IPv6 is tried first and fails, which means IPv4 is tried next and succeeds 840 | assert mock_socket.family == socket.AF_INET 841 | assert create_calls == [("dead:beef::", 80, 0, 0), ("107.6.106.83", 80)] 842 | 843 | 844 | @pytest.mark.asyncio 845 | @patch_socket 846 | async def test_ipv64_laddr_bind_fails_eyeballs_first_ipv6_fails( 847 | m_socket: ModuleType, 848 | ) -> None: 849 | mock_socket = mock.MagicMock( 850 | family=socket.AF_INET, 851 | type=socket.SOCK_STREAM, 852 | proto=socket.IPPROTO_TCP, 853 | fileno=mock.MagicMock(return_value=1), 854 | ) 855 | create_calls = [] 856 | 857 | def _socket(*args, **kw): 858 | for attr in kw: 859 | setattr(mock_socket, attr, kw[attr]) 860 | if kw["family"] == socket.AF_INET: 861 | mock_socket.bind.side_effect = OSError(5, "bind fail") 862 | 863 | return mock_socket 864 | 865 | async def _sock_connect( 866 | sock: socket.socket, address: Tuple[str, int, int, int] 867 | ) -> None: 868 | create_calls.append(address) 869 | if address[0] == "dead:beef::": 870 | raise OSError(5, "ipv6 fail") 871 | 872 | return None 873 | 874 | m_socket.socket = _socket # type: ignore 875 | ipv6_addr_info = ( 876 | socket.AF_INET6, 877 | socket.SOCK_STREAM, 878 | socket.IPPROTO_TCP, 879 | "", 880 | ("dead:beef::", 80, 0, 0), 881 | ) 882 | ipv6_addr_info_2 = ( 883 | socket.AF_INET6, 884 | socket.SOCK_STREAM, 885 | socket.IPPROTO_TCP, 886 | "", 887 | ("dead:aaaa::", 80, 0, 0), 888 | ) 889 | ipv4_addr_info = ( 890 | socket.AF_INET, 891 | socket.SOCK_STREAM, 892 | socket.IPPROTO_TCP, 893 | "", 894 | ("107.6.106.83", 80), 895 | ) 896 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 897 | local_addr_infos = [ 898 | ( 899 | socket.AF_INET6, 900 | socket.SOCK_STREAM, 901 | socket.IPPROTO_TCP, 902 | "", 903 | ("::1", 0, 0, 0), 904 | ), 905 | ( 906 | socket.AF_INET, 907 | socket.SOCK_STREAM, 908 | socket.IPPROTO_TCP, 909 | "", 910 | ("127.0.0.1", 0), 911 | ), 912 | ] 913 | loop = asyncio.get_running_loop() 914 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 915 | OSError, match="ipv6 fail" 916 | ): 917 | assert ( 918 | await start_connection( 919 | addr_info, 920 | happy_eyeballs_delay=0.3, 921 | interleave=1, 922 | local_addr_infos=local_addr_infos, 923 | ) 924 | == mock_socket 925 | ) 926 | 927 | # We only tried IPv6 since bind to IPv4 failed 928 | assert create_calls == [("dead:beef::", 80, 0, 0)] 929 | 930 | 931 | @pytest.mark.asyncio 932 | @patch_socket 933 | async def test_ipv64_laddr_bind_fails_eyeballs_interleave_first__ipv6_fails( 934 | m_socket: ModuleType, 935 | ) -> None: 936 | mock_socket = mock.MagicMock( 937 | family=socket.AF_INET, 938 | type=socket.SOCK_STREAM, 939 | proto=socket.IPPROTO_TCP, 940 | fileno=mock.MagicMock(return_value=1), 941 | ) 942 | create_calls = [] 943 | 944 | def _socket(*args, **kw): 945 | for attr in kw: 946 | setattr(mock_socket, attr, kw[attr]) 947 | if kw["family"] == socket.AF_INET: 948 | mock_socket.bind.side_effect = OSError(5, "bind fail") 949 | 950 | return mock_socket 951 | 952 | async def _sock_connect( 953 | sock: socket.socket, address: Tuple[str, int, int, int] 954 | ) -> None: 955 | create_calls.append(address) 956 | if address[0] == "dead:beef::": 957 | raise OSError(5, "ipv6 fail") 958 | 959 | return None 960 | 961 | m_socket.socket = _socket # type: ignore 962 | ipv6_addr_info = ( 963 | socket.AF_INET6, 964 | socket.SOCK_STREAM, 965 | socket.IPPROTO_TCP, 966 | "", 967 | ("dead:beef::", 80, 0, 0), 968 | ) 969 | ipv6_addr_info_2 = ( 970 | socket.AF_INET6, 971 | socket.SOCK_STREAM, 972 | socket.IPPROTO_TCP, 973 | "", 974 | ("dead:aaaa::", 80, 0, 0), 975 | ) 976 | ipv4_addr_info = ( 977 | socket.AF_INET, 978 | socket.SOCK_STREAM, 979 | socket.IPPROTO_TCP, 980 | "", 981 | ("107.6.106.83", 80), 982 | ) 983 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 984 | local_addr_infos = [ 985 | ( 986 | socket.AF_INET6, 987 | socket.SOCK_STREAM, 988 | socket.IPPROTO_TCP, 989 | "", 990 | ("::1", 0, 0, 0), 991 | ), 992 | ( 993 | socket.AF_INET, 994 | socket.SOCK_STREAM, 995 | socket.IPPROTO_TCP, 996 | "", 997 | ("127.0.0.1", 0), 998 | ), 999 | ] 1000 | loop = asyncio.get_running_loop() 1001 | with mock.patch.object(loop, "sock_connect", _sock_connect): 1002 | assert ( 1003 | await start_connection( 1004 | addr_info, 1005 | happy_eyeballs_delay=0.3, 1006 | interleave=2, 1007 | local_addr_infos=local_addr_infos, 1008 | ) 1009 | == mock_socket 1010 | ) 1011 | 1012 | # IPv6 is tried first and fails, which means IPv4 is tried next but the laddr 1013 | # build fails so we move on to the next IPv6 and it succeeds 1014 | assert create_calls == [("dead:beef::", 80, 0, 0), ("dead:aaaa::", 80, 0, 0)] 1015 | assert mock_socket.family == socket.AF_INET6 1016 | 1017 | 1018 | @pytest.mark.asyncio 1019 | @patch_socket 1020 | async def test_ipv64_laddr_socket_fails( 1021 | m_socket: ModuleType, 1022 | ) -> None: 1023 | mock_socket = mock.MagicMock( 1024 | family=socket.AF_INET, 1025 | type=socket.SOCK_STREAM, 1026 | proto=socket.IPPROTO_TCP, 1027 | fileno=mock.MagicMock(return_value=1), 1028 | ) 1029 | create_calls = [] 1030 | 1031 | def _socket(*args, **kw): 1032 | raise Exception("Something really went wrong") 1033 | 1034 | async def _sock_connect( 1035 | sock: socket.socket, address: Tuple[str, int, int, int] 1036 | ) -> None: 1037 | create_calls.append(address) 1038 | if address[0] == "dead:beef::": 1039 | raise OSError(5, "ipv6 fail") 1040 | 1041 | return None 1042 | 1043 | m_socket.socket = _socket # type: ignore 1044 | ipv6_addr_info = ( 1045 | socket.AF_INET6, 1046 | socket.SOCK_STREAM, 1047 | socket.IPPROTO_TCP, 1048 | "", 1049 | ("dead:beef::", 80, 0, 0), 1050 | ) 1051 | ipv6_addr_info_2 = ( 1052 | socket.AF_INET6, 1053 | socket.SOCK_STREAM, 1054 | socket.IPPROTO_TCP, 1055 | "", 1056 | ("dead:aaaa::", 80, 0, 0), 1057 | ) 1058 | ipv4_addr_info = ( 1059 | socket.AF_INET, 1060 | socket.SOCK_STREAM, 1061 | socket.IPPROTO_TCP, 1062 | "", 1063 | ("107.6.106.83", 80), 1064 | ) 1065 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1066 | local_addr_infos = [ 1067 | ( 1068 | socket.AF_INET6, 1069 | socket.SOCK_STREAM, 1070 | socket.IPPROTO_TCP, 1071 | "", 1072 | ("::1", 0, 0, 0), 1073 | ), 1074 | ( 1075 | socket.AF_INET, 1076 | socket.SOCK_STREAM, 1077 | socket.IPPROTO_TCP, 1078 | "", 1079 | ("127.0.0.1", 0), 1080 | ), 1081 | ] 1082 | loop = asyncio.get_running_loop() 1083 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1084 | Exception, match="Something really went wrong" 1085 | ): 1086 | assert ( 1087 | await start_connection( 1088 | addr_info, 1089 | local_addr_infos=local_addr_infos, 1090 | ) 1091 | == mock_socket 1092 | ) 1093 | 1094 | # All binds failed 1095 | assert create_calls == [] 1096 | 1097 | 1098 | @pytest.mark.asyncio 1099 | @patch_socket 1100 | async def test_ipv64_laddr_socket_blocking_fails( 1101 | m_socket: ModuleType, 1102 | ) -> None: 1103 | mock_socket = mock.MagicMock( 1104 | family=socket.AF_INET, 1105 | type=socket.SOCK_STREAM, 1106 | proto=socket.IPPROTO_TCP, 1107 | fileno=mock.MagicMock(return_value=1), 1108 | ) 1109 | create_calls = [] 1110 | 1111 | def _socket(*args, **kw): 1112 | for attr in kw: 1113 | setattr(mock_socket, attr, kw[attr]) 1114 | mock_socket.setblocking.side_effect = Exception("Something really went wrong") 1115 | return mock_socket 1116 | 1117 | async def _sock_connect( 1118 | sock: socket.socket, address: Tuple[str, int, int, int] 1119 | ) -> None: 1120 | create_calls.append(address) 1121 | if address[0] == "dead:beef::": 1122 | raise OSError(5, "ipv6 fail") 1123 | 1124 | return None 1125 | 1126 | m_socket.socket = _socket # type: ignore 1127 | ipv6_addr_info = ( 1128 | socket.AF_INET6, 1129 | socket.SOCK_STREAM, 1130 | socket.IPPROTO_TCP, 1131 | "", 1132 | ("dead:beef::", 80, 0, 0), 1133 | ) 1134 | ipv6_addr_info_2 = ( 1135 | socket.AF_INET6, 1136 | socket.SOCK_STREAM, 1137 | socket.IPPROTO_TCP, 1138 | "", 1139 | ("dead:aaaa::", 80, 0, 0), 1140 | ) 1141 | ipv4_addr_info = ( 1142 | socket.AF_INET, 1143 | socket.SOCK_STREAM, 1144 | socket.IPPROTO_TCP, 1145 | "", 1146 | ("107.6.106.83", 80), 1147 | ) 1148 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1149 | local_addr_infos = [ 1150 | ( 1151 | socket.AF_INET6, 1152 | socket.SOCK_STREAM, 1153 | socket.IPPROTO_TCP, 1154 | "", 1155 | ("::1", 0, 0, 0), 1156 | ), 1157 | ( 1158 | socket.AF_INET, 1159 | socket.SOCK_STREAM, 1160 | socket.IPPROTO_TCP, 1161 | "", 1162 | ("127.0.0.1", 0), 1163 | ), 1164 | ] 1165 | loop = asyncio.get_running_loop() 1166 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1167 | Exception, match="Something really went wrong" 1168 | ): 1169 | assert ( 1170 | await start_connection( 1171 | addr_info, 1172 | local_addr_infos=local_addr_infos, 1173 | ) 1174 | == mock_socket 1175 | ) 1176 | 1177 | # All binds failed 1178 | assert create_calls == [] 1179 | 1180 | 1181 | @pytest.mark.asyncio 1182 | @patch_socket 1183 | async def test_ipv64_laddr_eyeballs_ipv4_only_tried( 1184 | m_socket: ModuleType, 1185 | ) -> None: 1186 | mock_socket = mock.MagicMock( 1187 | family=socket.AF_INET, 1188 | type=socket.SOCK_STREAM, 1189 | proto=socket.IPPROTO_TCP, 1190 | fileno=mock.MagicMock(return_value=1), 1191 | ) 1192 | create_calls = [] 1193 | 1194 | def _socket(*args, **kw): 1195 | for attr in kw: 1196 | setattr(mock_socket, attr, kw[attr]) 1197 | return mock_socket 1198 | 1199 | async def _sock_connect( 1200 | sock: socket.socket, address: Tuple[str, int, int, int] 1201 | ) -> None: 1202 | create_calls.append(address) 1203 | if address[0] == "dead:beef::": 1204 | raise OSError(5, "ipv6 fail") 1205 | 1206 | return None 1207 | 1208 | m_socket.socket = _socket # type: ignore 1209 | ipv6_addr_info = ( 1210 | socket.AF_INET6, 1211 | socket.SOCK_STREAM, 1212 | socket.IPPROTO_TCP, 1213 | "", 1214 | ("dead:beef::", 80, 0, 0), 1215 | ) 1216 | ipv6_addr_info_2 = ( 1217 | socket.AF_INET6, 1218 | socket.SOCK_STREAM, 1219 | socket.IPPROTO_TCP, 1220 | "", 1221 | ("dead:aaaa::", 80, 0, 0), 1222 | ) 1223 | ipv4_addr_info = ( 1224 | socket.AF_INET, 1225 | socket.SOCK_STREAM, 1226 | socket.IPPROTO_TCP, 1227 | "", 1228 | ("107.6.106.83", 80), 1229 | ) 1230 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1231 | local_addr_infos = [ 1232 | ( 1233 | socket.AF_INET, 1234 | socket.SOCK_STREAM, 1235 | socket.IPPROTO_TCP, 1236 | "", 1237 | ("127.0.0.1", 0), 1238 | ) 1239 | ] 1240 | loop = asyncio.get_running_loop() 1241 | with mock.patch.object(loop, "sock_connect", _sock_connect): 1242 | assert ( 1243 | await start_connection( 1244 | addr_info, 1245 | happy_eyeballs_delay=0.3, 1246 | interleave=2, 1247 | local_addr_infos=local_addr_infos, 1248 | ) 1249 | == mock_socket 1250 | ) 1251 | 1252 | # Only IPv4 addresses are tried because local_addr_infos is IPv4 1253 | assert mock_socket.family == socket.AF_INET 1254 | assert create_calls == [("107.6.106.83", 80)] 1255 | 1256 | 1257 | @patch_socket 1258 | @pytest.mark.asyncio 1259 | async def test_ipv64_laddr_bind_fails_all_eyeballs_interleave_first__ipv6_fails( 1260 | m_socket: ModuleType, 1261 | ) -> None: 1262 | mock_socket = mock.MagicMock( 1263 | family=socket.AF_INET, 1264 | type=socket.SOCK_STREAM, 1265 | proto=socket.IPPROTO_TCP, 1266 | fileno=mock.MagicMock(return_value=1), 1267 | ) 1268 | create_calls = [] 1269 | 1270 | def _socket(*args, **kw): 1271 | for attr in kw: 1272 | setattr(mock_socket, attr, kw[attr]) 1273 | mock_socket.bind.side_effect = OSError(4, "bind fail") 1274 | return mock_socket 1275 | 1276 | async def _sock_connect( 1277 | sock: socket.socket, address: Tuple[str, int, int, int] 1278 | ) -> None: 1279 | create_calls.append(address) 1280 | if address[0] == "dead:beef::": 1281 | raise OSError(5, "ipv6 fail") 1282 | 1283 | return None 1284 | 1285 | m_socket.socket = _socket # type: ignore 1286 | ipv6_addr_info = ( 1287 | socket.AF_INET6, 1288 | socket.SOCK_STREAM, 1289 | socket.IPPROTO_TCP, 1290 | "", 1291 | ("dead:beef::", 80, 0, 0), 1292 | ) 1293 | ipv6_addr_info_2 = ( 1294 | socket.AF_INET6, 1295 | socket.SOCK_STREAM, 1296 | socket.IPPROTO_TCP, 1297 | "", 1298 | ("dead:aaaa::", 80, 0, 0), 1299 | ) 1300 | ipv4_addr_info = ( 1301 | socket.AF_INET, 1302 | socket.SOCK_STREAM, 1303 | socket.IPPROTO_TCP, 1304 | "", 1305 | ("107.6.106.83", 80), 1306 | ) 1307 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1308 | local_addr_infos = [ 1309 | ( 1310 | socket.AF_INET6, 1311 | socket.SOCK_STREAM, 1312 | socket.IPPROTO_TCP, 1313 | "", 1314 | ("::1", 0, 0, 0), 1315 | ), 1316 | ( 1317 | socket.AF_INET, 1318 | socket.SOCK_STREAM, 1319 | socket.IPPROTO_TCP, 1320 | "", 1321 | ("127.0.0.1", 0), 1322 | ), 1323 | ] 1324 | loop = asyncio.get_running_loop() 1325 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1326 | OSError, match="Multiple exceptions" 1327 | ): 1328 | assert ( 1329 | await start_connection( 1330 | addr_info, 1331 | happy_eyeballs_delay=0.3, 1332 | interleave=2, 1333 | local_addr_infos=local_addr_infos, 1334 | ) 1335 | == mock_socket 1336 | ) 1337 | 1338 | # All binds failed 1339 | assert create_calls == [] 1340 | 1341 | 1342 | @patch_socket 1343 | @pytest.mark.asyncio 1344 | async def test_all_same_exception_and_same_errno( 1345 | m_socket: ModuleType, 1346 | ) -> None: 1347 | """Test that all exceptions are the same and have the same errno.""" 1348 | mock_socket = mock.MagicMock( 1349 | family=socket.AF_INET, 1350 | type=socket.SOCK_STREAM, 1351 | proto=socket.IPPROTO_TCP, 1352 | fileno=mock.MagicMock(return_value=1), 1353 | ) 1354 | create_calls = [] 1355 | 1356 | def _socket(*args, **kw): 1357 | for attr in kw: 1358 | setattr(mock_socket, attr, kw[attr]) 1359 | return mock_socket 1360 | 1361 | async def _sock_connect( 1362 | sock: socket.socket, address: Tuple[str, int, int, int] 1363 | ) -> None: 1364 | create_calls.append(address) 1365 | raise OSError(5, "all fail") 1366 | 1367 | m_socket.socket = _socket # type: ignore 1368 | ipv6_addr_info = ( 1369 | socket.AF_INET6, 1370 | socket.SOCK_STREAM, 1371 | socket.IPPROTO_TCP, 1372 | "", 1373 | ("dead:beef::", 80, 0, 0), 1374 | ) 1375 | ipv6_addr_info_2 = ( 1376 | socket.AF_INET6, 1377 | socket.SOCK_STREAM, 1378 | socket.IPPROTO_TCP, 1379 | "", 1380 | ("dead:aaaa::", 80, 0, 0), 1381 | ) 1382 | ipv4_addr_info = ( 1383 | socket.AF_INET, 1384 | socket.SOCK_STREAM, 1385 | socket.IPPROTO_TCP, 1386 | "", 1387 | ("107.6.106.83", 80), 1388 | ) 1389 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1390 | local_addr_infos = [ 1391 | ( 1392 | socket.AF_INET6, 1393 | socket.SOCK_STREAM, 1394 | socket.IPPROTO_TCP, 1395 | "", 1396 | ("::1", 0, 0, 0), 1397 | ), 1398 | ( 1399 | socket.AF_INET, 1400 | socket.SOCK_STREAM, 1401 | socket.IPPROTO_TCP, 1402 | "", 1403 | ("127.0.0.1", 0), 1404 | ), 1405 | ] 1406 | loop = asyncio.get_running_loop() 1407 | # We should get the same exception raised if they are all the same 1408 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1409 | OSError, match="all fail" 1410 | ) as exc_info: 1411 | assert ( 1412 | await start_connection( 1413 | addr_info, 1414 | happy_eyeballs_delay=0.3, 1415 | interleave=2, 1416 | local_addr_infos=local_addr_infos, 1417 | ) 1418 | == mock_socket 1419 | ) 1420 | 1421 | assert exc_info.value.errno == 5 1422 | 1423 | # All calls failed 1424 | assert create_calls == [ 1425 | ("dead:beef::", 80, 0, 0), 1426 | ("dead:aaaa::", 80, 0, 0), 1427 | ("107.6.106.83", 80), 1428 | ] 1429 | 1430 | 1431 | @patch_socket 1432 | @pytest.mark.asyncio 1433 | async def test_all_same_exception_and_with_different_errno( 1434 | m_socket: ModuleType, 1435 | ) -> None: 1436 | """Test no errno is set if all OSError have different errno.""" 1437 | mock_socket = mock.MagicMock( 1438 | family=socket.AF_INET, 1439 | type=socket.SOCK_STREAM, 1440 | proto=socket.IPPROTO_TCP, 1441 | fileno=mock.MagicMock(return_value=1), 1442 | ) 1443 | create_calls = [] 1444 | 1445 | def _socket(*args, **kw): 1446 | for attr in kw: 1447 | setattr(mock_socket, attr, kw[attr]) 1448 | return mock_socket 1449 | 1450 | async def _sock_connect( 1451 | sock: socket.socket, address: Tuple[str, int, int, int] 1452 | ) -> None: 1453 | create_calls.append(address) 1454 | raise OSError(len(create_calls), "all fail") 1455 | 1456 | m_socket.socket = _socket # type: ignore 1457 | ipv6_addr_info = ( 1458 | socket.AF_INET6, 1459 | socket.SOCK_STREAM, 1460 | socket.IPPROTO_TCP, 1461 | "", 1462 | ("dead:beef::", 80, 0, 0), 1463 | ) 1464 | ipv6_addr_info_2 = ( 1465 | socket.AF_INET6, 1466 | socket.SOCK_STREAM, 1467 | socket.IPPROTO_TCP, 1468 | "", 1469 | ("dead:aaaa::", 80, 0, 0), 1470 | ) 1471 | ipv4_addr_info = ( 1472 | socket.AF_INET, 1473 | socket.SOCK_STREAM, 1474 | socket.IPPROTO_TCP, 1475 | "", 1476 | ("107.6.106.83", 80), 1477 | ) 1478 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1479 | local_addr_infos = [ 1480 | ( 1481 | socket.AF_INET6, 1482 | socket.SOCK_STREAM, 1483 | socket.IPPROTO_TCP, 1484 | "", 1485 | ("::1", 0, 0, 0), 1486 | ), 1487 | ( 1488 | socket.AF_INET, 1489 | socket.SOCK_STREAM, 1490 | socket.IPPROTO_TCP, 1491 | "", 1492 | ("127.0.0.1", 0), 1493 | ), 1494 | ] 1495 | loop = asyncio.get_running_loop() 1496 | # We should get the same exception raised if they are all the same 1497 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1498 | OSError, match="all fail" 1499 | ) as exc_info: 1500 | assert ( 1501 | await start_connection( 1502 | addr_info, 1503 | happy_eyeballs_delay=0.3, 1504 | interleave=2, 1505 | local_addr_infos=local_addr_infos, 1506 | ) 1507 | == mock_socket 1508 | ) 1509 | 1510 | # No errno is set if they are all different 1511 | assert exc_info.value.errno is None 1512 | 1513 | # All calls failed 1514 | assert create_calls == [ 1515 | ("dead:beef::", 80, 0, 0), 1516 | ("dead:aaaa::", 80, 0, 0), 1517 | ("107.6.106.83", 80), 1518 | ] 1519 | 1520 | 1521 | @patch_socket 1522 | @pytest.mark.asyncio 1523 | async def test_uvloop_runtime_error( 1524 | m_socket: ModuleType, 1525 | ) -> None: 1526 | """ 1527 | Test RuntimeError is handled when connecting a socket with uvloop. 1528 | 1529 | Connecting a socket can raise a RuntimeError, OSError or ValueError. 1530 | 1531 | - OSError: If the address is invalid or the connection fails. 1532 | - ValueError: if a non-sock it passed (this should never happen). 1533 | https://github.com/python/cpython/blob/e44eebfc1eccdaaebc219accbfc705c9a9de068d/Lib/asyncio/selector_events.py#L271 1534 | - RuntimeError: If the file descriptor is already in use by a transport. 1535 | 1536 | We should never get ValueError since we are using the correct types. 1537 | 1538 | selector_events.py never seems to raise a RuntimeError, but it is possible 1539 | with uvloop. This test is to ensure that we handle it correctly. 1540 | """ 1541 | mock_socket = mock.MagicMock( 1542 | family=socket.AF_INET, 1543 | type=socket.SOCK_STREAM, 1544 | proto=socket.IPPROTO_TCP, 1545 | fileno=mock.MagicMock(return_value=1), 1546 | ) 1547 | create_calls = [] 1548 | 1549 | def _socket(*args, **kw): 1550 | for attr in kw: 1551 | setattr(mock_socket, attr, kw[attr]) 1552 | return mock_socket 1553 | 1554 | async def _sock_connect( 1555 | sock: socket.socket, address: Tuple[str, int, int, int] 1556 | ) -> None: 1557 | create_calls.append(address) 1558 | raise RuntimeError("all fail") 1559 | 1560 | m_socket.socket = _socket # type: ignore 1561 | ipv6_addr_info = ( 1562 | socket.AF_INET6, 1563 | socket.SOCK_STREAM, 1564 | socket.IPPROTO_TCP, 1565 | "", 1566 | ("dead:beef::", 80, 0, 0), 1567 | ) 1568 | ipv6_addr_info_2 = ( 1569 | socket.AF_INET6, 1570 | socket.SOCK_STREAM, 1571 | socket.IPPROTO_TCP, 1572 | "", 1573 | ("dead:aaaa::", 80, 0, 0), 1574 | ) 1575 | ipv4_addr_info = ( 1576 | socket.AF_INET, 1577 | socket.SOCK_STREAM, 1578 | socket.IPPROTO_TCP, 1579 | "", 1580 | ("107.6.106.83", 80), 1581 | ) 1582 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1583 | local_addr_infos = [ 1584 | ( 1585 | socket.AF_INET6, 1586 | socket.SOCK_STREAM, 1587 | socket.IPPROTO_TCP, 1588 | "", 1589 | ("::1", 0, 0, 0), 1590 | ), 1591 | ( 1592 | socket.AF_INET, 1593 | socket.SOCK_STREAM, 1594 | socket.IPPROTO_TCP, 1595 | "", 1596 | ("127.0.0.1", 0), 1597 | ), 1598 | ] 1599 | loop = asyncio.get_running_loop() 1600 | # We should get the same exception raised if they are all the same 1601 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1602 | RuntimeError, match="all fail" 1603 | ): 1604 | assert ( 1605 | await start_connection( 1606 | addr_info, 1607 | happy_eyeballs_delay=0.3, 1608 | interleave=2, 1609 | local_addr_infos=local_addr_infos, 1610 | ) 1611 | == mock_socket 1612 | ) 1613 | 1614 | # All calls failed 1615 | assert create_calls == [ 1616 | ("dead:beef::", 80, 0, 0), 1617 | ("dead:aaaa::", 80, 0, 0), 1618 | ("107.6.106.83", 80), 1619 | ] 1620 | 1621 | 1622 | @patch_socket 1623 | @pytest.mark.asyncio 1624 | async def test_uvloop_different_runtime_error( 1625 | m_socket: ModuleType, 1626 | ) -> None: 1627 | """Test different RuntimeErrors are handled when connecting a socket with uvloop.""" 1628 | mock_socket = mock.MagicMock( 1629 | family=socket.AF_INET, 1630 | type=socket.SOCK_STREAM, 1631 | proto=socket.IPPROTO_TCP, 1632 | fileno=mock.MagicMock(return_value=1), 1633 | ) 1634 | create_calls = [] 1635 | counter = 0 1636 | 1637 | def _socket(*args, **kw): 1638 | for attr in kw: 1639 | setattr(mock_socket, attr, kw[attr]) 1640 | return mock_socket 1641 | 1642 | async def _sock_connect( 1643 | sock: socket.socket, address: Tuple[str, int, int, int] 1644 | ) -> None: 1645 | create_calls.append(address) 1646 | nonlocal counter 1647 | counter += 1 1648 | raise RuntimeError(counter) 1649 | 1650 | m_socket.socket = _socket # type: ignore 1651 | ipv6_addr_info = ( 1652 | socket.AF_INET6, 1653 | socket.SOCK_STREAM, 1654 | socket.IPPROTO_TCP, 1655 | "", 1656 | ("dead:beef::", 80, 0, 0), 1657 | ) 1658 | ipv6_addr_info_2 = ( 1659 | socket.AF_INET6, 1660 | socket.SOCK_STREAM, 1661 | socket.IPPROTO_TCP, 1662 | "", 1663 | ("dead:aaaa::", 80, 0, 0), 1664 | ) 1665 | ipv4_addr_info = ( 1666 | socket.AF_INET, 1667 | socket.SOCK_STREAM, 1668 | socket.IPPROTO_TCP, 1669 | "", 1670 | ("107.6.106.83", 80), 1671 | ) 1672 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1673 | local_addr_infos = [ 1674 | ( 1675 | socket.AF_INET6, 1676 | socket.SOCK_STREAM, 1677 | socket.IPPROTO_TCP, 1678 | "", 1679 | ("::1", 0, 0, 0), 1680 | ), 1681 | ( 1682 | socket.AF_INET, 1683 | socket.SOCK_STREAM, 1684 | socket.IPPROTO_TCP, 1685 | "", 1686 | ("127.0.0.1", 0), 1687 | ), 1688 | ] 1689 | loop = asyncio.get_running_loop() 1690 | # We should get the same exception raised if they are all the same 1691 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1692 | RuntimeError, match="Multiple exceptions: 1, 2, 3" 1693 | ): 1694 | assert ( 1695 | await start_connection( 1696 | addr_info, 1697 | happy_eyeballs_delay=0.3, 1698 | interleave=2, 1699 | local_addr_infos=local_addr_infos, 1700 | ) 1701 | == mock_socket 1702 | ) 1703 | 1704 | # All calls failed 1705 | assert create_calls == [ 1706 | ("dead:beef::", 80, 0, 0), 1707 | ("dead:aaaa::", 80, 0, 0), 1708 | ("107.6.106.83", 80), 1709 | ] 1710 | 1711 | 1712 | @patch_socket 1713 | @pytest.mark.asyncio 1714 | async def test_uvloop_mixing_os_and_runtime_error( 1715 | m_socket: ModuleType, 1716 | ) -> None: 1717 | """Test uvloop raising OSError and RuntimeError.""" 1718 | mock_socket = mock.MagicMock( 1719 | family=socket.AF_INET, 1720 | type=socket.SOCK_STREAM, 1721 | proto=socket.IPPROTO_TCP, 1722 | fileno=mock.MagicMock(return_value=1), 1723 | ) 1724 | create_calls = [] 1725 | counter = 0 1726 | 1727 | def _socket(*args, **kw): 1728 | for attr in kw: 1729 | setattr(mock_socket, attr, kw[attr]) 1730 | return mock_socket 1731 | 1732 | async def _sock_connect( 1733 | sock: socket.socket, address: Tuple[str, int, int, int] 1734 | ) -> None: 1735 | create_calls.append(address) 1736 | nonlocal counter 1737 | counter += 1 1738 | if counter == 1: 1739 | raise RuntimeError(counter) 1740 | raise OSError(counter, f"all fail {counter}") 1741 | 1742 | m_socket.socket = _socket # type: ignore 1743 | ipv6_addr_info = ( 1744 | socket.AF_INET6, 1745 | socket.SOCK_STREAM, 1746 | socket.IPPROTO_TCP, 1747 | "", 1748 | ("dead:beef::", 80, 0, 0), 1749 | ) 1750 | ipv6_addr_info_2 = ( 1751 | socket.AF_INET6, 1752 | socket.SOCK_STREAM, 1753 | socket.IPPROTO_TCP, 1754 | "", 1755 | ("dead:aaaa::", 80, 0, 0), 1756 | ) 1757 | ipv4_addr_info = ( 1758 | socket.AF_INET, 1759 | socket.SOCK_STREAM, 1760 | socket.IPPROTO_TCP, 1761 | "", 1762 | ("107.6.106.83", 80), 1763 | ) 1764 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1765 | local_addr_infos = [ 1766 | ( 1767 | socket.AF_INET6, 1768 | socket.SOCK_STREAM, 1769 | socket.IPPROTO_TCP, 1770 | "", 1771 | ("::1", 0, 0, 0), 1772 | ), 1773 | ( 1774 | socket.AF_INET, 1775 | socket.SOCK_STREAM, 1776 | socket.IPPROTO_TCP, 1777 | "", 1778 | ("127.0.0.1", 0), 1779 | ), 1780 | ] 1781 | loop = asyncio.get_running_loop() 1782 | # We should get the same exception raised if they are all the same 1783 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1784 | OSError, match="Multiple exceptions: 1" 1785 | ): 1786 | assert ( 1787 | await start_connection( 1788 | addr_info, 1789 | happy_eyeballs_delay=0.3, 1790 | interleave=2, 1791 | local_addr_infos=local_addr_infos, 1792 | ) 1793 | == mock_socket 1794 | ) 1795 | 1796 | # All calls failed 1797 | assert create_calls == [ 1798 | ("dead:beef::", 80, 0, 0), 1799 | ("dead:aaaa::", 80, 0, 0), 1800 | ("107.6.106.83", 80), 1801 | ] 1802 | 1803 | 1804 | @patch_socket 1805 | @pytest.mark.asyncio 1806 | async def test_handling_system_exit( 1807 | m_socket: ModuleType, 1808 | ) -> None: 1809 | """Test handling SystemExit.""" 1810 | 1811 | class MockSystemExit(BaseException): 1812 | """Mock SystemExit.""" 1813 | 1814 | mock_socket = mock.MagicMock( 1815 | family=socket.AF_INET, 1816 | type=socket.SOCK_STREAM, 1817 | proto=socket.IPPROTO_TCP, 1818 | fileno=mock.MagicMock(return_value=1), 1819 | ) 1820 | create_calls = [] 1821 | 1822 | def _socket(*args, **kw): 1823 | for attr in kw: 1824 | setattr(mock_socket, attr, kw[attr]) 1825 | return mock_socket 1826 | 1827 | async def _sock_connect( 1828 | sock: socket.socket, address: Tuple[str, int, int, int] 1829 | ) -> None: 1830 | create_calls.append(address) 1831 | raise MockSystemExit 1832 | 1833 | m_socket.socket = _socket # type: ignore 1834 | ipv6_addr_info = ( 1835 | socket.AF_INET6, 1836 | socket.SOCK_STREAM, 1837 | socket.IPPROTO_TCP, 1838 | "", 1839 | ("dead:beef::", 80, 0, 0), 1840 | ) 1841 | ipv6_addr_info_2 = ( 1842 | socket.AF_INET6, 1843 | socket.SOCK_STREAM, 1844 | socket.IPPROTO_TCP, 1845 | "", 1846 | ("dead:aaaa::", 80, 0, 0), 1847 | ) 1848 | ipv4_addr_info = ( 1849 | socket.AF_INET, 1850 | socket.SOCK_STREAM, 1851 | socket.IPPROTO_TCP, 1852 | "", 1853 | ("107.6.106.83", 80), 1854 | ) 1855 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1856 | local_addr_infos = [ 1857 | ( 1858 | socket.AF_INET6, 1859 | socket.SOCK_STREAM, 1860 | socket.IPPROTO_TCP, 1861 | "", 1862 | ("::1", 0, 0, 0), 1863 | ), 1864 | ( 1865 | socket.AF_INET, 1866 | socket.SOCK_STREAM, 1867 | socket.IPPROTO_TCP, 1868 | "", 1869 | ("127.0.0.1", 0), 1870 | ), 1871 | ] 1872 | loop = asyncio.get_running_loop() 1873 | with pytest.raises(MockSystemExit), mock.patch.object( 1874 | loop, "sock_connect", _sock_connect 1875 | ), mock.patch.object(_staggered, "RE_RAISE_EXCEPTIONS", (MockSystemExit,)): 1876 | await start_connection( 1877 | addr_info, 1878 | happy_eyeballs_delay=0.3, 1879 | interleave=2, 1880 | local_addr_infos=local_addr_infos, 1881 | ) 1882 | 1883 | # Stopped after the first call 1884 | assert create_calls == [ 1885 | ("dead:beef::", 80, 0, 0), 1886 | ] 1887 | 1888 | 1889 | @patch_socket 1890 | @pytest.mark.asyncio 1891 | async def test_cancellation_is_not_swallowed( 1892 | m_socket: ModuleType, 1893 | ) -> None: 1894 | """Test that cancellation is not swallowed.""" 1895 | mock_socket = mock.MagicMock( 1896 | family=socket.AF_INET, 1897 | type=socket.SOCK_STREAM, 1898 | proto=socket.IPPROTO_TCP, 1899 | fileno=mock.MagicMock(return_value=1), 1900 | ) 1901 | create_calls = [] 1902 | 1903 | def _socket(*args, **kw): 1904 | for attr in kw: 1905 | setattr(mock_socket, attr, kw[attr]) 1906 | return mock_socket 1907 | 1908 | async def _sock_connect( 1909 | sock: socket.socket, address: Tuple[str, int, int, int] 1910 | ) -> None: 1911 | create_calls.append(address) 1912 | await asyncio.sleep(1000) 1913 | 1914 | m_socket.socket = _socket # type: ignore 1915 | ipv6_addr_info = ( 1916 | socket.AF_INET6, 1917 | socket.SOCK_STREAM, 1918 | socket.IPPROTO_TCP, 1919 | "", 1920 | ("dead:beef::", 80, 0, 0), 1921 | ) 1922 | ipv6_addr_info_2 = ( 1923 | socket.AF_INET6, 1924 | socket.SOCK_STREAM, 1925 | socket.IPPROTO_TCP, 1926 | "", 1927 | ("dead:aaaa::", 80, 0, 0), 1928 | ) 1929 | ipv4_addr_info = ( 1930 | socket.AF_INET, 1931 | socket.SOCK_STREAM, 1932 | socket.IPPROTO_TCP, 1933 | "", 1934 | ("107.6.106.83", 80), 1935 | ) 1936 | addr_info = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 1937 | local_addr_infos = [ 1938 | ( 1939 | socket.AF_INET6, 1940 | socket.SOCK_STREAM, 1941 | socket.IPPROTO_TCP, 1942 | "", 1943 | ("::1", 0, 0, 0), 1944 | ), 1945 | ( 1946 | socket.AF_INET, 1947 | socket.SOCK_STREAM, 1948 | socket.IPPROTO_TCP, 1949 | "", 1950 | ("127.0.0.1", 0), 1951 | ), 1952 | ] 1953 | loop = asyncio.get_running_loop() 1954 | # We should get the same exception raised if they are all the same 1955 | with mock.patch.object(loop, "sock_connect", _sock_connect), pytest.raises( 1956 | asyncio.CancelledError 1957 | ): 1958 | task = asyncio.create_task( 1959 | start_connection( 1960 | addr_info, 1961 | happy_eyeballs_delay=0.3, 1962 | interleave=2, 1963 | local_addr_infos=local_addr_infos, 1964 | ) 1965 | ) 1966 | await asyncio.sleep(0) 1967 | task.cancel() 1968 | await task 1969 | 1970 | # After calls are cancelled now more are made 1971 | assert create_calls == [ 1972 | ("dead:beef::", 80, 0, 0), 1973 | ] 1974 | 1975 | 1976 | @pytest.mark.asyncio 1977 | @pytest.mark.parametrize( 1978 | "connect_side_effect", 1979 | [ 1980 | OSError("during connect"), 1981 | asyncio.CancelledError("during connect"), 1982 | ], 1983 | ) 1984 | @patch_socket 1985 | async def test_single_addr_info_close_errors( 1986 | m_socket: ModuleType, connect_side_effect: BaseException 1987 | ) -> None: 1988 | mock_socket = mock.MagicMock( 1989 | family=socket.AF_INET, 1990 | type=socket.SOCK_STREAM, 1991 | proto=socket.IPPROTO_TCP, 1992 | fileno=mock.MagicMock(return_value=1), 1993 | ) 1994 | mock_socket.configure_mock( 1995 | **{ 1996 | "connect.side_effect": connect_side_effect, 1997 | "close.side_effect": OSError("during close"), 1998 | } 1999 | ) 2000 | 2001 | def _socket(*args, **kw): 2002 | return mock_socket 2003 | 2004 | m_socket.socket = _socket # type: ignore 2005 | 2006 | addr_info = [ 2007 | ( 2008 | socket.AF_INET, 2009 | socket.SOCK_STREAM, 2010 | socket.IPPROTO_TCP, 2011 | "", 2012 | ("107.6.106.82", 80), 2013 | ) 2014 | ] 2015 | with pytest.raises(OSError, match="during close"): 2016 | await start_connection(addr_info) 2017 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | from aiohappyeyeballs import start_connection 2 | 3 | 4 | def test_init(): 5 | assert start_connection is not None 6 | -------------------------------------------------------------------------------- /tests/test_staggered.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from collections.abc import Callable as CallableABC 4 | from functools import partial 5 | from typing import Callable as CallableTyping 6 | 7 | import pytest 8 | 9 | from aiohappyeyeballs._staggered import Callable, staggered_race 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_one_winners(): 14 | """Test that there is only one winner when there is no await in the coro.""" 15 | winners = [] 16 | 17 | async def coro(idx): 18 | winners.append(idx) 19 | return idx 20 | 21 | coros = [partial(coro, idx) for idx in range(4)] 22 | 23 | winner, index, excs = await staggered_race( 24 | coros, 25 | delay=None, 26 | ) 27 | assert len(winners) == 1 28 | assert winners == [0] 29 | assert winner == 0 30 | assert index == 0 31 | assert excs == [None] 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_multiple_winners(): 36 | """Test multiple winners are handled correctly.""" 37 | loop = asyncio.get_running_loop() 38 | winners = [] 39 | finish = loop.create_future() 40 | 41 | async def coro(idx): 42 | await finish 43 | winners.append(idx) 44 | return idx 45 | 46 | coros = [partial(coro, idx) for idx in range(4)] 47 | 48 | task = loop.create_task(staggered_race(coros, delay=0.00001)) 49 | await asyncio.sleep(0.1) 50 | loop.call_soon(finish.set_result, None) 51 | winner, index, excs = await task 52 | assert len(winners) == 4 53 | assert winners == [0, 1, 2, 3] 54 | assert winner == 0 55 | assert index == 0 56 | assert excs == [None, None, None, None] 57 | 58 | 59 | @pytest.mark.skipif(sys.version_info < (3, 12), reason="requires python3.12 or higher") 60 | def test_multiple_winners_eager_task_factory(): 61 | """Test multiple winners are handled correctly.""" 62 | loop = asyncio.new_event_loop() 63 | eager_task_factory = asyncio.create_eager_task_factory(asyncio.Task) 64 | loop.set_task_factory(eager_task_factory) 65 | asyncio.set_event_loop(None) 66 | 67 | async def run(): 68 | winners = [] 69 | finish = loop.create_future() 70 | 71 | async def coro(idx): 72 | await finish 73 | winners.append(idx) 74 | return idx 75 | 76 | coros = [partial(coro, idx) for idx in range(4)] 77 | 78 | task = loop.create_task(staggered_race(coros, delay=0.00001)) 79 | await asyncio.sleep(0.1) 80 | loop.call_soon(finish.set_result, None) 81 | winner, index, excs = await task 82 | assert len(winners) == 4 83 | assert winners == [0, 1, 2, 3] 84 | assert winner == 0 85 | assert index == 0 86 | assert excs == [None, None, None, None] 87 | 88 | loop.run_until_complete(run()) 89 | loop.close() 90 | 91 | 92 | def test_callable_import_from_typing(): 93 | """ 94 | Test that Callable is imported from typing. 95 | 96 | PY3.9: https://github.com/python/cpython/issues/87131 97 | 98 | Drop this test when we drop support for Python 3.9. 99 | """ 100 | assert Callable is CallableTyping 101 | assert Callable is not CallableABC 102 | -------------------------------------------------------------------------------- /tests/test_staggered_cpython.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for staggered_race. 3 | 4 | These tests are copied from cpython to ensure our implementation is 5 | compatible with the one in cpython. 6 | """ 7 | 8 | import asyncio 9 | import unittest 10 | 11 | from aiohappyeyeballs._staggered import staggered_race 12 | 13 | 14 | def tearDownModule(): 15 | asyncio.set_event_loop_policy(None) 16 | 17 | 18 | class StaggeredTests(unittest.IsolatedAsyncioTestCase): 19 | async def test_empty(self): 20 | winner, index, excs = await staggered_race( 21 | [], 22 | delay=None, 23 | ) 24 | 25 | self.assertIs(winner, None) 26 | self.assertIs(index, None) 27 | self.assertEqual(excs, []) 28 | 29 | async def test_one_successful(self): 30 | async def coro(index): 31 | return f"Res: {index}" 32 | 33 | winner, index, excs = await staggered_race( 34 | [ 35 | lambda: coro(0), 36 | lambda: coro(1), 37 | ], 38 | delay=None, 39 | ) 40 | 41 | self.assertEqual(winner, "Res: 0") 42 | self.assertEqual(index, 0) 43 | self.assertEqual(excs, [None]) 44 | 45 | async def test_first_error_second_successful(self): 46 | async def coro(index): 47 | if index == 0: 48 | raise ValueError(index) 49 | return f"Res: {index}" 50 | 51 | winner, index, excs = await staggered_race( 52 | [ 53 | lambda: coro(0), 54 | lambda: coro(1), 55 | ], 56 | delay=None, 57 | ) 58 | 59 | self.assertEqual(winner, "Res: 1") 60 | self.assertEqual(index, 1) 61 | self.assertEqual(len(excs), 2) 62 | self.assertIsInstance(excs[0], ValueError) 63 | self.assertIs(excs[1], None) 64 | 65 | async def test_first_timeout_second_successful(self): 66 | async def coro(index): 67 | if index == 0: 68 | await asyncio.sleep(10) # much bigger than delay 69 | return f"Res: {index}" 70 | 71 | winner, index, excs = await staggered_race( 72 | [ 73 | lambda: coro(0), 74 | lambda: coro(1), 75 | ], 76 | delay=0.1, 77 | ) 78 | 79 | self.assertEqual(winner, "Res: 1") 80 | self.assertEqual(index, 1) 81 | self.assertEqual(len(excs), 2) 82 | self.assertIsInstance(excs[0], asyncio.CancelledError) 83 | self.assertIs(excs[1], None) 84 | 85 | async def test_none_successful(self): 86 | async def coro(index): 87 | raise ValueError(index) 88 | 89 | for delay in [None, 0, 0.1, 1]: 90 | with self.subTest(delay=delay): 91 | winner, index, excs = await staggered_race( 92 | [ 93 | lambda: coro(0), 94 | lambda: coro(1), 95 | ], 96 | delay=delay, 97 | ) 98 | 99 | self.assertIs(winner, None) 100 | self.assertIs(index, None) 101 | self.assertEqual(len(excs), 2) 102 | self.assertIsInstance(excs[0], ValueError) 103 | self.assertIsInstance(excs[1], ValueError) 104 | 105 | async def test_long_delay_early_failure(self): 106 | async def coro(index): 107 | await asyncio.sleep(0) # Dummy coroutine for the 1 case 108 | if index == 0: 109 | await asyncio.sleep(0.1) # Dummy coroutine 110 | raise ValueError(index) 111 | 112 | return f"Res: {index}" 113 | 114 | winner, index, excs = await staggered_race( 115 | [ 116 | lambda: coro(0), 117 | lambda: coro(1), 118 | ], 119 | delay=10, 120 | ) 121 | 122 | self.assertEqual(winner, "Res: 1") 123 | self.assertEqual(index, 1) 124 | self.assertEqual(len(excs), 2) 125 | self.assertIsInstance(excs[0], ValueError) 126 | self.assertIsNone(excs[1]) 127 | 128 | def test_loop_argument(self): 129 | loop = asyncio.new_event_loop() 130 | 131 | async def coro(): 132 | self.assertEqual(loop, asyncio.get_running_loop()) 133 | return "coro" 134 | 135 | async def main(): 136 | winner, index, excs = await staggered_race([coro], delay=0.1, loop=loop) 137 | 138 | self.assertEqual(winner, "coro") 139 | self.assertEqual(index, 0) 140 | 141 | loop.run_until_complete(main()) 142 | loop.close() 143 | 144 | 145 | if __name__ == "__main__": 146 | unittest.main() 147 | -------------------------------------------------------------------------------- /tests/test_staggered_cpython_eager_task_factory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests staggered_race and eager_task_factory with asyncio.Task. 3 | 4 | These tests are copied from cpython to ensure our implementation is 5 | compatible with the one in cpython. 6 | """ 7 | 8 | import asyncio 9 | import sys 10 | import unittest 11 | 12 | from aiohappyeyeballs._staggered import staggered_race 13 | 14 | 15 | def tearDownModule(): 16 | asyncio.set_event_loop_policy(None) 17 | 18 | 19 | class EagerTaskFactoryLoopTests(unittest.TestCase): 20 | def close_loop(self, loop): 21 | loop.close() 22 | 23 | def set_event_loop(self, loop, *, cleanup=True): 24 | if loop is None: 25 | raise AssertionError("loop is None") 26 | # ensure that the event loop is passed explicitly in asyncio 27 | asyncio.set_event_loop(None) 28 | if cleanup: 29 | self.addCleanup(self.close_loop, loop) 30 | 31 | def tearDown(self): 32 | asyncio.set_event_loop(None) 33 | self.doCleanups() 34 | 35 | def setUp(self): 36 | if sys.version_info < (3, 12): 37 | self.skipTest("eager_task_factory is only available in Python 3.12+") 38 | 39 | super().setUp() 40 | self.loop = asyncio.new_event_loop() 41 | self.eager_task_factory = asyncio.create_eager_task_factory(asyncio.Task) 42 | self.loop.set_task_factory(self.eager_task_factory) 43 | self.set_event_loop(self.loop) 44 | 45 | def test_staggered_race_with_eager_tasks(self): 46 | # See https://github.com/python/cpython/issues/124309 47 | 48 | async def fail(): 49 | await asyncio.sleep(0) 50 | raise ValueError("no good") 51 | 52 | async def run(): 53 | winner, index, excs = await staggered_race( 54 | [ 55 | lambda: asyncio.sleep(2, result="sleep2"), 56 | lambda: asyncio.sleep(1, result="sleep1"), 57 | lambda: fail(), 58 | ], 59 | delay=0.25, 60 | ) 61 | self.assertEqual(winner, "sleep1") 62 | self.assertEqual(index, 1) 63 | assert index is not None 64 | self.assertIsNone(excs[index]) 65 | self.assertIsInstance(excs[0], asyncio.CancelledError) 66 | self.assertIsInstance(excs[2], ValueError) 67 | 68 | self.loop.run_until_complete(run()) 69 | 70 | def test_staggered_race_with_eager_tasks_no_delay(self): 71 | # See https://github.com/python/cpython/issues/124309 72 | async def fail(): 73 | raise ValueError("no good") 74 | 75 | async def run(): 76 | winner, index, excs = await staggered_race( 77 | [ 78 | lambda: fail(), 79 | lambda: asyncio.sleep(1, result="sleep1"), 80 | lambda: asyncio.sleep(0, result="sleep0"), 81 | ], 82 | delay=None, 83 | ) 84 | self.assertEqual(winner, "sleep1") 85 | self.assertEqual(index, 1) 86 | assert index is not None 87 | self.assertIsNone(excs[index]) 88 | self.assertIsInstance(excs[0], ValueError) 89 | self.assertEqual(len(excs), 2) 90 | 91 | self.loop.run_until_complete(run()) 92 | 93 | 94 | if __name__ == "__main__": 95 | if sys.version_info >= (3, 12): 96 | unittest.main() 97 | -------------------------------------------------------------------------------- /tests/test_types.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable as CallableABC 2 | from typing import Callable as CallableTyping 3 | 4 | from aiohappyeyeballs.types import Callable 5 | 6 | 7 | def test_callable_import_from_typing(): 8 | """ 9 | Test that Callable is imported from typing. 10 | 11 | PY3.9: https://github.com/python/cpython/issues/87131 12 | """ 13 | assert Callable is CallableTyping 14 | assert Callable is not CallableABC 15 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from typing import List 3 | 4 | import pytest 5 | 6 | from aiohappyeyeballs import ( 7 | AddrInfoType, 8 | addr_to_addr_infos, 9 | pop_addr_infos_interleave, 10 | remove_addr_infos, 11 | ) 12 | 13 | 14 | def test_pop_addr_infos_interleave(): 15 | """Test pop_addr_infos_interleave.""" 16 | ipv6_addr_info = ( 17 | socket.AF_INET6, 18 | socket.SOCK_STREAM, 19 | socket.IPPROTO_TCP, 20 | "", 21 | ("dead:beef::", 80, 0, 0), 22 | ) 23 | ipv6_addr_info_2 = ( 24 | socket.AF_INET6, 25 | socket.SOCK_STREAM, 26 | socket.IPPROTO_TCP, 27 | "", 28 | ("dead:aaaa::", 80, 0, 0), 29 | ) 30 | ipv4_addr_info = ( 31 | socket.AF_INET, 32 | socket.SOCK_STREAM, 33 | socket.IPPROTO_TCP, 34 | "", 35 | ("107.6.106.83", 80), 36 | ) 37 | addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 38 | addr_info_copy = addr_info.copy() 39 | pop_addr_infos_interleave(addr_info_copy, 1) 40 | assert addr_info_copy == [ipv6_addr_info_2] 41 | pop_addr_infos_interleave(addr_info_copy, 1) 42 | assert addr_info_copy == [] 43 | addr_info_copy = addr_info.copy() 44 | pop_addr_infos_interleave(addr_info_copy, 2) 45 | assert addr_info_copy == [] 46 | addr_info_copy = addr_info.copy() 47 | pop_addr_infos_interleave(addr_info_copy) 48 | assert addr_info_copy == [ipv6_addr_info_2] 49 | 50 | 51 | def test_remove_addr_infos(): 52 | """Test remove_addr_infos.""" 53 | ipv6_addr_info = ( 54 | socket.AF_INET6, 55 | socket.SOCK_STREAM, 56 | socket.IPPROTO_TCP, 57 | "", 58 | ("dead:beef::", 80, 0, 0), 59 | ) 60 | ipv6_addr_info_2 = ( 61 | socket.AF_INET6, 62 | socket.SOCK_STREAM, 63 | socket.IPPROTO_TCP, 64 | "", 65 | ("dead:aaaa::", 80, 0, 0), 66 | ) 67 | ipv4_addr_info = ( 68 | socket.AF_INET, 69 | socket.SOCK_STREAM, 70 | socket.IPPROTO_TCP, 71 | "", 72 | ("107.6.106.83", 80), 73 | ) 74 | addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 75 | addr_info_copy = addr_info.copy() 76 | remove_addr_infos( 77 | addr_info_copy, 78 | ("dead:beef::", 80, 0, 0), 79 | ) 80 | assert addr_info_copy == [ipv6_addr_info_2, ipv4_addr_info] 81 | remove_addr_infos(addr_info_copy, ("dead:aaaa::", 80, 0, 0)) 82 | assert addr_info_copy == [ipv4_addr_info] 83 | remove_addr_infos(addr_info_copy, ("107.6.106.83", 80)) 84 | assert addr_info_copy == [] 85 | 86 | 87 | def test_remove_addr_infos_slow_path(): 88 | """Test remove_addr_infos with mis-matched formatting.""" 89 | ipv6_addr_info = ( 90 | socket.AF_INET6, 91 | socket.SOCK_STREAM, 92 | socket.IPPROTO_TCP, 93 | "", 94 | ("dead:beef::", 80, 0, 0), 95 | ) 96 | ipv6_addr_info_2 = ( 97 | socket.AF_INET6, 98 | socket.SOCK_STREAM, 99 | socket.IPPROTO_TCP, 100 | "", 101 | ("dead:aaaa::", 80, 0, 0), 102 | ) 103 | ipv4_addr_info = ( 104 | socket.AF_INET, 105 | socket.SOCK_STREAM, 106 | socket.IPPROTO_TCP, 107 | "", 108 | ("107.6.106.83", 80), 109 | ) 110 | addr_info: List[AddrInfoType] = [ipv6_addr_info, ipv6_addr_info_2, ipv4_addr_info] 111 | addr_info_copy = addr_info.copy() 112 | remove_addr_infos( 113 | addr_info_copy, ("dead:beef:0000:0000:0000:0000:0000:0000", 80, 0, 0) 114 | ) 115 | assert addr_info_copy == [ipv6_addr_info_2, ipv4_addr_info] 116 | remove_addr_infos( 117 | addr_info_copy, ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0) 118 | ) 119 | assert addr_info_copy == [ipv4_addr_info] 120 | with pytest.raises( 121 | ValueError, match=r"Address \('107.6.106.2', 80\) not found in addr_infos" 122 | ): 123 | remove_addr_infos(addr_info_copy, ("107.6.106.2", 80)) 124 | assert addr_info_copy == [ipv4_addr_info] 125 | 126 | 127 | def test_addr_to_addr_infos(): 128 | """Test addr_to_addr_infos.""" 129 | assert addr_to_addr_infos(("1.2.3.4", 43)) == [ 130 | ( 131 | socket.AF_INET, 132 | socket.SOCK_STREAM, 133 | socket.IPPROTO_TCP, 134 | "", 135 | ("1.2.3.4", 43), 136 | ) 137 | ] 138 | assert addr_to_addr_infos( 139 | ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0) 140 | ) == [ 141 | ( 142 | socket.AF_INET6, 143 | socket.SOCK_STREAM, 144 | socket.IPPROTO_TCP, 145 | "", 146 | ("dead:aaaa:0000:0000:0000:0000:0000:0000", 80, 0, 0), 147 | ) 148 | ] 149 | assert addr_to_addr_infos(("dead:aaaa::", 80, 0, 0)) == [ 150 | ( 151 | socket.AF_INET6, 152 | socket.SOCK_STREAM, 153 | socket.IPPROTO_TCP, 154 | "", 155 | ("dead:aaaa::", 80, 0, 0), 156 | ) 157 | ] 158 | assert addr_to_addr_infos(("dead:aaaa::", 80)) == [ 159 | ( 160 | socket.AF_INET6, 161 | socket.SOCK_STREAM, 162 | socket.IPPROTO_TCP, 163 | "", 164 | ("dead:aaaa::", 80, 0, 0), 165 | ) 166 | ] 167 | assert addr_to_addr_infos(("dead:aaaa::", 80, 1)) == [ 168 | ( 169 | socket.AF_INET6, 170 | socket.SOCK_STREAM, 171 | socket.IPPROTO_TCP, 172 | "", 173 | ("dead:aaaa::", 80, 1, 0), 174 | ) 175 | ] 176 | assert addr_to_addr_infos(("dead:aaaa::", 80, 1, 1)) == [ 177 | ( 178 | socket.AF_INET6, 179 | socket.SOCK_STREAM, 180 | socket.IPPROTO_TCP, 181 | "", 182 | ("dead:aaaa::", 80, 1, 1), 183 | ) 184 | ] 185 | assert addr_to_addr_infos(None) is None 186 | --------------------------------------------------------------------------------