diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..a5dbbcba7 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake . diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4a15f2acb..d1093657e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,7 @@ version: 2 +enable-beta-ecosystems: true updates: -- package-ecosystem: pip +- package-ecosystem: uv directory: "/" schedule: interval: daily diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..37d7f43b4 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,70 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '31 21 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Learn more about CodeQL language support at https://git.io/codeql-language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6001397c8..e11c79dac 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -8,8 +8,7 @@ on: branches: [ master ] pull_request: branches: [ master ] - workflow_dispatch: - branches: [ master ] + workflow_dispatch: {} jobs: build: @@ -17,21 +16,23 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] - + python-version: ["3.9", "3.10", "3.11", "3.12"] + poetry-version: ["main"] steps: - - uses: actions/checkout@v2.4.0 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2.3.1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-tests.txt + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.4.30" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Install the project + run: uv sync --all-extras --dev - name: Lint with flake8 - run: | - flake8 sshuttle tests --count --show-source --statistics - - name: Test with pytest - run: | - PYTHONPATH=$PWD pytest + run: uv run flake8 sshuttle tests --count --show-source --statistics + - name: Run the automated tests + run: uv run pytest -v diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..7b4f1d2e7 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,66 @@ +on: + push: + branches: + - master + +name: release-please + +jobs: + + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.MY_RELEASE_PLEASE_TOKEN }} + release-type: python + + build-pypi: + name: Build for pypi + needs: [release-please] + if: ${{ needs.release-please.outputs.release_created == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: 3.12 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.4.30" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: Build project + run: uv build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + upload-pypi: + name: Upload to pypi + needs: [build-pypi] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/sshuttle + permissions: + id-token: write + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index b79343aa4..2ecd9c703 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -/sshuttle/version.py /tmp/ +/.coverage /.cache/ /.eggs/ /.tox/ @@ -15,4 +15,6 @@ /.redo /.pytest_cache/ /.python-version -.vscode/ +/.direnv/ +/result +/.vscode/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..6b94913ab --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-20.04 + tools: + python: "3.10" + jobs: + post_install: + - pip install uv + - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy + +sphinx: + configuration: docs/conf.py diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..cb51a3125 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +python 3.10.6 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..8fbc218a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +## [1.3.1](https://github.com/sshuttle/sshuttle/compare/v1.3.0...v1.3.1) (2025-03-25) + + +### Bug Fixes + +* add pycodestyle config ([5942376](https://github.com/sshuttle/sshuttle/commit/5942376090395d0a8dfe38fe012a519268199341)) +* add python lint tools ([ae3c022](https://github.com/sshuttle/sshuttle/commit/ae3c022d1d67de92f1c4712d06eb8ae76c970624)) +* correct bad version number at runtime ([7b66253](https://github.com/sshuttle/sshuttle/commit/7b662536ba92d724ed8f86a32a21282fea66047c)) +* Restore "nft" method ([375810a](https://github.com/sshuttle/sshuttle/commit/375810a9a8910a51db22c9fe4c0658c39b16c9e7)) + +## [1.3.0](https://github.com/sshuttle/sshuttle/compare/v1.2.0...v1.3.0) (2025-02-23) + + +### Features + +* switch to a network namespace on Linux ([8a123d9](https://github.com/sshuttle/sshuttle/commit/8a123d9762b84f168a8ca8c75f73e590954e122d)) + + +### Bug Fixes + +* prevent UnicodeDecodeError parsing iptables rule with comments ([cbe3d1e](https://github.com/sshuttle/sshuttle/commit/cbe3d1e402cac9d3fbc818fe0cb8a87be2e94348)) +* remove temp build hack ([1f5e6ce](https://github.com/sshuttle/sshuttle/commit/1f5e6cea703db33761fb1c3f999b9624cf3bc7ad)) +* support ':' sign in password ([7fa927e](https://github.com/sshuttle/sshuttle/commit/7fa927ef8ceea6b1b2848ca433b8b3e3b63f0509)) + + +### Documentation + +* replace nix-env with nix-shell ([340ccc7](https://github.com/sshuttle/sshuttle/commit/340ccc705ebd9499f14f799fcef0b5d2a8055fb4)) +* update installation instructions ([a2d405a](https://github.com/sshuttle/sshuttle/commit/a2d405a6a7f9d1a301311a109f8411f2fe8deb37)) + +## [1.2.0](https://github.com/sshuttle/sshuttle/compare/v1.1.2...v1.2.0) (2025-02-07) + + +### Features + +* Add release-please to build workflow ([d910b64](https://github.com/sshuttle/sshuttle/commit/d910b64be77fd7ef2a5f169b780bfda95e67318d)) + + +### Bug Fixes + +* Add support for Python 3.11 and Python 3.11 ([a3396a4](https://github.com/sshuttle/sshuttle/commit/a3396a443df14d3bafc3d25909d9221aa182b8fc)) +* bad file descriptor error in windows, fix pytest errors ([d4d0fa9](https://github.com/sshuttle/sshuttle/commit/d4d0fa945d50606360aa7c5f026a0f190b026c68)) +* drop Python 3.8 support ([1084c0f](https://github.com/sshuttle/sshuttle/commit/1084c0f2458c1595b00963b3bd54bd667e4cfc9f)) +* ensure poetry works for Python 3.9 ([693ee40](https://github.com/sshuttle/sshuttle/commit/693ee40c485c70f353326eb0e8f721f984850f5c)) +* fix broken workflow_dispatch CI rule ([4b6f7c6](https://github.com/sshuttle/sshuttle/commit/4b6f7c6a656a752552295863092d3b8af0b42b31)) +* Remove more references to legacy Python versions ([339b522](https://github.com/sshuttle/sshuttle/commit/339b5221bc33254329f79f2374f6114be6f30aed)) +* replace requirements.txt files with poetry ([85dc319](https://github.com/sshuttle/sshuttle/commit/85dc3199a332f9f9f0e4c6037c883a8f88dc09ca)) +* replace requirements.txt files with poetry (2) ([d08f78a](https://github.com/sshuttle/sshuttle/commit/d08f78a2d9777951d7e18f6eaebbcdd279d7683a)) +* replace requirements.txt files with poetry (3) ([62da705](https://github.com/sshuttle/sshuttle/commit/62da70510e8a1f93e8b38870fdebdbace965cd8e)) +* replace requirements.txt files with poetry (4) ([9bcedf1](https://github.com/sshuttle/sshuttle/commit/9bcedf19049e5b3a8ae26818299cc518ec03a926)) +* update nix flake to fix problems ([cda60a5](https://github.com/sshuttle/sshuttle/commit/cda60a52331c7102cff892b9b77c8321e276680a)) +* use Python >= 3.10 for docs ([bf29464](https://github.com/sshuttle/sshuttle/commit/bf294643e283cef9fb285d44e307e958686caf46)) diff --git a/CHANGES.rst b/CHANGES.rst index 2bd289b82..f8d6afc9e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,12 +1,9 @@ ========== Change log ========== -All notable changes to this project will be documented in this file. The format -is based on `Keep a Changelog`_ and this project -adheres to `Semantic Versioning`_. +Release notes now moved to https://github.com/sshuttle/sshuttle/releases/ -.. _`Keep a Changelog`: http://keepachangelog.com/ -.. _`Semantic Versioning`: http://semver.org/ +These are the old release notes. 1.0.5 - 2020-12-29 diff --git a/README.rst b/README.rst index 05ec05501..6cf500a24 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ sshuttle: where transparent proxy meets VPN meets ssh As far as I know, sshuttle is the only program that solves the following common case: -- Your client machine (or router) is Linux, FreeBSD, or MacOS. +- Your client machine (or router) is Linux, FreeBSD, MacOS or Windows. - You have access to a remote network via ssh. @@ -30,80 +30,9 @@ common case: Obtaining sshuttle ------------------ -- Ubuntu 16.04 or later:: - - apt-get install sshuttle - -- Debian stretch or later:: - - apt-get install sshuttle - -- Arch Linux:: - - pacman -S sshuttle - -- Fedora:: - - dnf install sshuttle - -- openSUSE:: - - zypper in sshuttle - -- Gentoo:: - - emerge -av net-proxy/sshuttle - -- NixOS:: - - nix-env -iA nixos.sshuttle - -- From PyPI:: - - sudo pip install sshuttle - -- Clone:: - - git clone https://github.com/sshuttle/sshuttle.git - cd sshuttle - sudo ./setup.py install - -- FreeBSD:: - - # ports - cd /usr/ports/net/py-sshuttle && make install clean - # pkg - pkg install py36-sshuttle - -- macOS, via MacPorts:: - - sudo port selfupdate - sudo port install sshuttle - -It is also possible to install into a virtualenv as a non-root user. - -- From PyPI:: - - virtualenv -p python3 /tmp/sshuttle - . /tmp/sshuttle/bin/activate - pip install sshuttle - -- Clone:: - - virtualenv -p python3 /tmp/sshuttle - . /tmp/sshuttle/bin/activate - git clone https://github.com/sshuttle/sshuttle.git - cd sshuttle - ./setup.py install - -- Homebrew:: - - brew install sshuttle - -- Nix:: - - nix-env -iA nixpkgs.sshuttle +Please see the documentation_. +.. _Documentation: https://sshuttle.readthedocs.io/en/stable/installation.html Documentation ------------- diff --git a/bin/sudoers-add b/bin/sudoers-add deleted file mode 100755 index e359d46eb..000000000 --- a/bin/sudoers-add +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash -# William Mantly -# MIT License -# https://github.com/wmantly/sudoers-add - -NEWLINE=$'\n' -CONTENT="" -ME="$(basename "$(test -L "$0" && readlink "$0" || echo "$0")")" - -if [ "$1" == "--help" ] || [ "$1" == "-h" ]; then - echo "Usage: $ME [file_path] [sudoers-file-name]" - echo "Usage: [content] | $ME sudoers-file-name" - echo "This will take a sudoers config validate it and add it to /etc/sudoers.d/{sudoers-file-name}" - echo "The config can come from a file, first usage example or piped in second example." - - exit 0 -fi - -if [ "$1" == "" ]; then - (>&2 echo "This command take at lest one argument. See $ME --help") - - exit 1 -fi - -if [ "$2" == "" ]; then - FILE_NAME=$1 - shift -else - FILE_NAME=$2 -fi - -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root" - - exit 1 -fi - -while read -r line -do - CONTENT+="${line}${NEWLINE}" -done < "${1:-/dev/stdin}" - -if [ "$CONTENT" == "" ]; then - (>&2 echo "No config content specified. See $ME --help") - exit 1 -fi - -if [ "$FILE_NAME" == "" ]; then - (>&2 echo "No sudoers file name specified. See $ME --help") - exit 1 -fi - -# Verify that the resulting file name begins with /etc/sudoers.d -FILE_NAME="$(realpath "/etc/sudoers.d/$FILE_NAME")" -if [[ "$FILE_NAME" != "/etc/sudoers.d/"* ]] ; then - echo -n "Invalid sudoers filename: Final sudoers file " - echo "location ($FILE_NAME) does not begin with /etc/sudoers.d" - exit 1 -fi - -# Make a temp file to hold the sudoers config -umask 077 -TEMP_FILE=$(mktemp) -echo "$CONTENT" > "$TEMP_FILE" - -# Make sure the content is valid -visudo_STDOUT=$(visudo -c -f "$TEMP_FILE" 2>&1) -visudo_code=$? -# The temp file is no longer needed -rm "$TEMP_FILE" - -if [ $visudo_code -eq 0 ]; then - echo "$CONTENT" > "$FILE_NAME" - chmod 0440 "$FILE_NAME" - echo "The sudoers file $FILE_NAME has been successfully created!" - - exit 0 -else - echo "Invalid sudoers config!" - echo "$visudo_STDOUT" - - exit 1 -fi - diff --git a/docs/conf.py b/docs/conf.py index 6b15f80f1..0d33ba506 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ import sys import os sys.path.insert(0, os.path.abspath('..')) -import sshuttle.version # NOQA +import sshuttle # NOQA # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -56,7 +56,7 @@ # built documents. # # The full version, including alpha/beta/rc tags. -release = sshuttle.version.version +release = sshuttle.__version__ # The short X.Y version. version = '.'.join(release.split('.')[:2]) @@ -103,7 +103,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'furo' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/installation.rst b/docs/installation.rst index 4dc18f31e..0d56594ab 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,24 +1,84 @@ Installation ============ +- Ubuntu 16.04 or later:: + + apt-get install sshuttle + +- Debian stretch or later:: + + apt-get install sshuttle + +- Arch Linux:: + + pacman -S sshuttle + +- Fedora:: + + dnf install sshuttle + +- openSUSE:: + + zypper in sshuttle + +- Gentoo:: + + emerge -av net-proxy/sshuttle + +- NixOS:: + + nix-env -iA nixos.sshuttle + - From PyPI:: - pip install sshuttle + sudo pip install sshuttle + +- Clone:: + + git clone https://github.com/sshuttle/sshuttle.git + cd sshuttle + sudo ./setup.py install + +- FreeBSD:: + + # ports + cd /usr/ports/net/py-sshuttle && make install clean + # pkg + pkg install py39-sshuttle + +- OpenBSD:: + + pkg_add sshuttle -- Debian package manager:: +- macOS, via MacPorts:: - sudo apt install sshuttle + sudo port selfupdate + sudo port install sshuttle + +It is also possible to install into a virtualenv as a non-root user. + +- From PyPI:: + + python3 -m venv /tmp/sshuttle + . /tmp/sshuttle/bin/activate + pip install sshuttle - Clone:: git clone https://github.com/sshuttle/sshuttle.git cd sshuttle - ./setup.py install + python3 -m venv /tmp/sshuttle + . /tmp/sshuttle/bin/activate + python -m pip install . + +- Homebrew:: + brew install sshuttle -Optionally after installation ------------------------------ +- Nix:: -- Add to sudoers file:: + nix-shell -p sshuttle - sshuttle --sudoers +- Windows:: + + pip install sshuttle diff --git a/docs/manpage.rst b/docs/manpage.rst index 39e166bae..49022ec86 100644 --- a/docs/manpage.rst +++ b/docs/manpage.rst @@ -181,6 +181,18 @@ Options in a non-standard location or you want to provide extra options to the ssh command, for example, ``-e 'ssh -v'``. +.. option:: --remote-shell + + For Windows targets, specify configured remote shell program alternative to defacto posix shell. + It would be either ``cmd`` or ``powershell`` unless something like git-bash is in use. + +.. option:: --no-cmd-delimiter + + Do not add a double dash (--) delimiter before invoking Python on + the remote host. This option is useful when the ssh command used + to connect is a custom command that does not interpret this + delimiter correctly. + .. option:: --seed-hosts A comma-separated list of hostnames to use to @@ -242,8 +254,8 @@ Options .. option:: --disable-ipv6 - Disable IPv6 support for methods that support it (nft, tproxy, and - pf). + Disable IPv6 support for methods that support it (nat, nft, + tproxy, and pf). .. option:: --firewall @@ -262,28 +274,23 @@ Options makes it a lot easier to debug and test the :option:`--auto-hosts` feature. -.. option:: --sudoers - - sshuttle will auto generate the proper sudoers.d config file and add it. - Once this is completed, sshuttle will exit and tell the user if - it succeed or not. Do not call this options with sudo, it may generate a - incorrect config file. - .. option:: --sudoers-no-modify - sshuttle will auto generate the proper sudoers.d config and print it to - stdout. The option will not modify the system at all. - -.. option:: --sudoers-user + sshuttle prints a configuration to stdout which allows a user to + run sshuttle without a password. This option is INSECURE because, + with some cleverness, it also allows the user to run any command + as root without a password. The output also includes a suggested + method for you to install the configuration. - Set the user name or group with %group_name for passwordless operation. - Default is the current user.set ALL for all users. Only works with - --sudoers or --sudoers-no-modify option. + Use --sudoers-user to modify the user that it applies to. -.. option:: --sudoers-filename +.. option:: --sudoers-user - Set the file name for the sudoers.d file to be added. Default is - "sshuttle_auto". Only works with --sudoers. + Set the user name or group with %group_name for passwordless + operation. Default is the current user. Set to ALL for all users + (NOT RECOMMENDED: See note about security in --sudoers-no-modify + documentation above). Only works with the --sudoers-no-modify + option. .. option:: -t , --tmark= @@ -326,6 +333,18 @@ annotations. For example:: 192.168.63.0/24 +Environment Variable +-------------------- + +You can specify command line options with the `SSHUTTLE_ARGS` environment +variable. If a given option is defined in both the environment variable and +command line, the value on the command line will take precedence. + +For example:: + + SSHUTTLE_ARGS="-e 'ssh -v' --dns" sshuttle -r example.com 0/0 + + Examples -------- @@ -460,7 +479,7 @@ Packet-level forwarding (eg. using the tun/tap devices on Linux) seems elegant at first, but it results in several problems, notably the 'tcp over tcp' problem. The tcp protocol depends fundamentally on packets being dropped -in order to implement its congestion control agorithm; if +in order to implement its congestion control algorithm; if you pass tcp packets through a tcp-based tunnel (such as ssh), the inner tcp packets will never be dropped, and so the inner tcp stream's congestion control will be diff --git a/docs/requirements.rst b/docs/requirements.rst index 8278b3180..3d4d3ec95 100644 --- a/docs/requirements.rst +++ b/docs/requirements.rst @@ -6,7 +6,7 @@ Client side Requirements - sudo, or root access on your client machine. (The server doesn't need admin access.) -- Python 3.6 or greater. +- Python 3.9 or greater. Linux with NAT method @@ -41,7 +41,7 @@ Supports: * IPv4 TCP * IPv4 UDP -* IPv6 DNS +* IPv4 DNS * IPv6 TCP * IPv6 UDP * IPv6 DNS @@ -65,14 +65,13 @@ Requires: Windows ~~~~~~~ -Not officially supported, however can be made to work with Vagrant. Requires -cmd.exe with Administrator access. See :doc:`windows` for more information. +Experimental built-in support available. See :doc:`windows` for more information. Server side Requirements ------------------------ -- Python 3.6 or greater. +- Python 3.9 or greater. Additional Suggested Software diff --git a/docs/tproxy.rst b/docs/tproxy.rst index c47cf78a6..3a54e63e0 100644 --- a/docs/tproxy.rst +++ b/docs/tproxy.rst @@ -1,6 +1,6 @@ TPROXY ====== -TPROXY is the only method that has full support of IPv6 and UDP. +TPROXY is the only method that supports UDP. There are some things you need to consider for TPROXY to work: diff --git a/docs/usage.rst b/docs/usage.rst index bf1dfc2b0..c535884c5 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -11,7 +11,7 @@ Forward all traffic:: sshuttle -r username@sshserver 0.0.0.0/0 - Use the :option:`sshuttle -r` parameter to specify a remote server. - One some systems, you may also need to use the :option:`sshuttle -x` + On some systems, you may also need to use the :option:`sshuttle -x` parameter to exclude sshserver or sshserver:22 so that your local machine can communicate directly to sshserver without it being redirected by sshuttle. @@ -71,44 +71,23 @@ admin access on the server. Sudoers File ------------ -sshuttle can auto-generate the proper sudoers.d file using the current user -for Linux and OSX. Doing this will allow sshuttle to run without asking for -the local sudo password and to give users who do not have sudo access -ability to run sshuttle:: - sshuttle --sudoers +sshuttle can generate a sudoers.d file for Linux and MacOS. This +allows one or more users to run sshuttle without entering the +local sudo password. **WARNING:** This option is *insecure* +because, with some cleverness, it also allows these users to run any +command (via the --ssh-cmd option) as root without a password. -DO NOT run this command with sudo, it will ask for your sudo password when -it is needed. - -A costume user or group can be set with the : -option:`sshuttle --sudoers --sudoers-username {user_descriptor}` option. Valid -values for this vary based on how your system is configured. Values such as -usernames, groups pre-pended with `%` and sudoers user aliases will work. See -the sudoers manual for more information on valid user specif actions. -The options must be used with `--sudoers`:: - - sshuttle --sudoers --sudoers-user mike - sshuttle --sudoers --sudoers-user %sudo - -The name of the file to be added to sudoers.d can be configured as well. This -is mostly not necessary but can be useful for giving more than one user -access to sshuttle. The default is `sshuttle_auto`:: - - sshuttle --sudoer --sudoers-filename sshuttle_auto_mike - sshuttle --sudoer --sudoers-filename sshuttle_auto_tommy - -You can also see what configuration will be added to your system without -modifying anything. This can be helpful if the auto feature does not work, or -you want more control. This option also works with `--sudoers-username`. -`--sudoers-filename` has no effect with this option:: +To print a sudo configuration file and see a suggested way to install it, run:: sshuttle --sudoers-no-modify -This will simply sprint the generated configuration to STDOUT. Example:: - - 08:40 PM william$ sshuttle --sudoers-no-modify - - Cmnd_Alias SSHUTTLE304 = /usr/bin/env PYTHONPATH=/usr/local/lib/python2.7/dist-packages/sshuttle-0.78.5.dev30+gba5e6b5.d20180909-py2.7.egg /usr/bin/python /usr/local/bin/sshuttle --method auto --firewall +A custom user or group can be set with the +:option:`sshuttle --sudoers-no-modify --sudoers-user {user_descriptor}` +option. Valid values for this vary based on how your system is configured. +Values such as usernames, groups prepended with `%` and sudoers user +aliases will work. See the sudoers manual for more information on valid +user-specified actions. The option must be used with `--sudoers-no-modify`:: - william ALL=NOPASSWD: SSHUTTLE304 + sshuttle --sudoers-no-modify --sudoers-user mike + sshuttle --sudoers-no-modify --sudoers-user %sudo diff --git a/docs/windows.rst b/docs/windows.rst index 9103ec948..d7462902f 100644 --- a/docs/windows.rst +++ b/docs/windows.rst @@ -1,7 +1,16 @@ Microsoft Windows ================= -Currently there is no built in support for running sshuttle directly on -Microsoft Windows. + +Experimental native support:: + +Experimental built-in support for Windows is available through `windivert` method. +You have to install https://pypi.org/project/pydivert package. You need Administrator privileges to use windivert method + +Notes +- sshuttle should be executed from admin shell (Automatic firewall process admin elevation is not available) +- TCP/IPv4 supported (IPv6/UDP/DNS are not available) + +Use Linux VM on Windows:: What we can really do is to create a Linux VM with Vagrant (or simply Virtualbox if you like). In the Vagrant settings, remember to turn on bridged diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..640115a1b --- /dev/null +++ b/flake.lock @@ -0,0 +1,133 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1740743217, + "narHash": "sha256-brsCRzLqimpyhORma84c3W2xPbIidZlIc3JGIuQVSNI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b27ba4eb322d9d2bf2dc9ada9fd59442f50c8d7c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "pyproject-build-systems": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ], + "uv2nix": [ + "uv2nix" + ] + }, + "locked": { + "lastModified": 1740362541, + "narHash": "sha256-S8Mno07MspggOv/xIz5g8hB2b/C5HPiX8E+rXzKY+5U=", + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "rev": "e151741c848ba92331af91f4e47640a1fb82be19", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "build-system-pkgs", + "type": "github" + } + }, + "pyproject-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1739758351, + "narHash": "sha256-Aoa4dEoC7Hf6+gFVk/SDquZTMFlmlfsgdTWuqQxzePs=", + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "rev": "1329712f7f9af3a8b270764ba338a455b7323811", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "pyproject.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "pyproject-build-systems": "pyproject-build-systems", + "pyproject-nix": "pyproject-nix", + "uv2nix": "uv2nix" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "uv2nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "pyproject-nix": [ + "pyproject-nix" + ] + }, + "locked": { + "lastModified": 1740497536, + "narHash": "sha256-K+8wsVooqhaqyxuvew3+62mgOfRLJ7whv7woqPU3Ypo=", + "owner": "pyproject-nix", + "repo": "uv2nix", + "rev": "d01fd3a141755ad5d5b93dd9fcbd76d6401f5bac", + "type": "github" + }, + "original": { + "owner": "pyproject-nix", + "repo": "uv2nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..615f1852e --- /dev/null +++ b/flake.nix @@ -0,0 +1,117 @@ +{ + description = "Transparent proxy server that works as a poor man's VPN. Forwards over ssh. Doesn't require admin. Works with Linux and MacOS. Supports DNS tunneling."; + + inputs = { + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11"; + pyproject-nix = { + url = "github:pyproject-nix/pyproject.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + uv2nix = { + url = "github:pyproject-nix/uv2nix"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + pyproject-build-systems = { + url = "github:pyproject-nix/build-system-pkgs"; + inputs.pyproject-nix.follows = "pyproject-nix"; + inputs.uv2nix.follows = "uv2nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + pyproject-nix, + uv2nix, + pyproject-build-systems, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + inherit (nixpkgs) lib; + + pkgs = nixpkgs.legacyPackages.${system}; + + python = pkgs.python312; + + workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; }; + + # Create package overlay from workspace. + overlay = workspace.mkPyprojectOverlay { + sourcePreference = "sdist"; + }; + + # Extend generated overlay with build fixups + # + # Uv2nix can only work with what it has, and uv.lock is missing essential metadata to perform some builds. + # This is an additional overlay implementing build fixups. + # See: + # - https://pyproject-nix.github.io/uv2nix/FAQ.html + pyprojectOverrides = + final: prev: + # Implement build fixups here. + # Note that uv2nix is _not_ using Nixpkgs buildPythonPackage. + # It's using https://pyproject-nix.github.io/pyproject.nix/build.html + let + inherit (final) resolveBuildSystem; + inherit (builtins) mapAttrs; + + # Build system dependencies specified in the shape expected by resolveBuildSystem + # The empty lists below are lists of optional dependencies. + # + # A package `foo` with specification written as: + # `setuptools-scm[toml]` in pyproject.toml would be written as + # `foo.setuptools-scm = [ "toml" ]` in Nix + buildSystemOverrides = { + chardet.setuptools = [ ]; + colorlog.setuptools = [ ]; + python-debian.setuptools = [ ]; + pluggy.setuptools = [ ]; + pathspec.flit-core = [ ]; + packaging.flit-core = [ ]; + }; + + in + mapAttrs ( + name: spec: + prev.${name}.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs ++ resolveBuildSystem spec; + }) + ) buildSystemOverrides; + + pythonSet = + (pkgs.callPackage pyproject-nix.build.packages { + inherit python; + }).overrideScope + ( + lib.composeManyExtensions [ + pyproject-build-systems.overlays.default + overlay + pyprojectOverrides + ] + ); + + inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication; + package = mkApplication { + venv = pythonSet.mkVirtualEnv "sshuttle" workspace.deps.default; + package = pythonSet.sshuttle; + }; + in + { + packages = { + sshuttle = package; + default = package; + }; + devShells.default = pkgs.mkShell { + packages = [ + pkgs.uv + ]; + }; + } + ); +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..c06f1ff5b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[project] +authors = [ + {name = "Brian May", email = "brian@linuxpenguins.xyz"}, +] +license = {text = "LGPL-2.1"} +requires-python = "<4.0,>=3.9" +dependencies = [] +name = "sshuttle" +version = "1.3.1" +description = "Transparent proxy server that works as a poor man's VPN. Forwards over ssh. Doesn't require admin. Works with Linux and MacOS. Supports DNS tunneling." +readme = "README.rst" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU Lesser General Public License v2 or later (LGPLv2+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: System :: Networking", +] + +[project.scripts] +sshuttle = "sshuttle.cmdline:main" + +[dependency-groups] +dev = [ + "pytest<9.0.0,>=8.0.1", + "pytest-cov<7.0,>=4.1", + "flake8<8.0.0,>=7.0.0", + "pyflakes<4.0.0,>=3.2.0", + "bump2version<2.0.0,>=1.0.1", + "twine<7,>=5", + "black>=25.1.0", + "jedi-language-server>=0.44.0", + "pylsp-mypy>=0.7.0", + "python-lsp-server>=1.12.2", + "ruff>=0.11.2", +] +docs = [ + "sphinx==8.1.3; python_version ~= \"3.10\"", + "furo==2024.8.6", +] + +[tool.uv] +default-groups = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.jj" +] diff --git a/requirements-tests.txt b/requirements-tests.txt deleted file mode 100644 index b59009e8a..000000000 --- a/requirements-tests.txt +++ /dev/null @@ -1,5 +0,0 @@ --r requirements.txt -pytest==6.2.5 -pytest-cov==3.0.0 -flake8==4.0.1 -pyflakes==2.4.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index be5de1ff9..000000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -setuptools-scm==6.3.2 diff --git a/scripts/Containerfile b/scripts/Containerfile new file mode 100644 index 000000000..3f6df8a68 --- /dev/null +++ b/scripts/Containerfile @@ -0,0 +1,39 @@ +# https://hub.docker.com/r/linuxserver/openssh-server/ +ARG BASE_IMAGE=docker.io/linuxserver/openssh-server:version-9.3_p2-r1 + +FROM ${BASE_IMAGE} as pyenv + +# https://github.com/pyenv/pyenv/wiki#suggested-build-environment +RUN apk add --no-cache build-base git libffi-dev openssl-dev bzip2-dev zlib-dev readline-dev sqlite-dev +ENV PYENV_ROOT=/pyenv +RUN curl https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash +RUN /pyenv/bin/pyenv install 3.10 +RUN /pyenv/bin/pyenv install 3.11 +RUN /pyenv/bin/pyenv install 3.12 +RUN bash -xc 'rm -rf /pyenv/{.git,plugins} /pyenv/versions/*/lib/*/{test,config,config-*linux-gnu}' && \ + find /pyenv -type d -name __pycache__ -exec rm -rf {} + && \ + find /pyenv -type f -name '*.py[co]' -delete + +FROM ${BASE_IMAGE} + +RUN apk add --no-cache bash nginx iperf3 + +# pyenv setup +ENV PYENV_ROOT=/pyenv +ENV PATH=/pyenv/shims:/pyenv/bin:$PATH +COPY --from=pyenv /pyenv /pyenv + +# OpenSSH Server variables +ENV PUID=1000 +ENV PGID=1000 +ENV PASSWORD_ACCESS=true +ENV USER_NAME=test +ENV USER_PASSWORD=test +ENV LOG_STDOUT=true + +# suppress linuxserver.io logo printing, chnage sshd config +RUN sed -i '1 a exec &>/dev/null' /etc/s6-overlay/s6-rc.d/init-adduser/run + +# https://www.linuxserver.io/blog/2019-09-14-customizing-our-containers +# To customize the container and start other components +COPY container.setup.sh /custom-cont-init.d/setup.sh diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..e9204c456 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,21 @@ +# Container based test bed for sshuttle + +```bash +test-bed up -d # start containers + +exec-sshuttle [--copy-id] [--server-py=2.7|3.10] [--client-py=2.7|3.10] [--sshuttle-bin=/path/to/sshuttle] [sshuttle-args...] + # --copy-id -> optionally do ssh-copy-id to make it passwordless for future runs + # --sshuttle-bin -> use another sshuttle binary instead of one from dev setup + # --server-py -> Python version to use in server. (manged by pyenv) + # --client-py -> Python version to use in client (manged by pyenv) + +exec-sshuttle node-1 # start sshuttle to connect to node-1 + +exec-tool curl node-1 # curl to nginx instance running on node1 via IP that is only reachable via sshuttle +exec-tool iperf3 node-1 # measure throughput to node-1 + +run-benchmark node-1 --client-py=3.10 + +``` + + diff --git a/scripts/compose.yml b/scripts/compose.yml new file mode 100644 index 000000000..5bdb4e539 --- /dev/null +++ b/scripts/compose.yml @@ -0,0 +1,34 @@ +name: sshuttle-testbed + +services: + node-1: + image: ghcr.io/sshuttle/sshuttle-testbed + container_name: sshuttle-testbed-node-1 + hostname: node-1 + cap_add: + - "NET_ADMIN" + environment: + - ADD_IP_ADDRESSES=10.55.1.77/24 + networks: + default: + ipv6_address: 2001:0DB8::551 + node-2: + image: ghcr.io/sshuttle/sshuttle-testbed + container_name: sshuttle-testbed-node-2 + hostname: node-2 + cap_add: + - "NET_ADMIN" + environment: + - ADD_IP_ADDRESSES=10.55.2.77/32 + networks: + default: + ipv6_address: 2001:0DB8::552 + +networks: + default: + driver: bridge + enable_ipv6: true + ipam: + config: + - subnet: 2001:0DB8::/112 + # internal: true \ No newline at end of file diff --git a/scripts/container.setup.sh b/scripts/container.setup.sh new file mode 100755 index 000000000..255e22f9a --- /dev/null +++ b/scripts/container.setup.sh @@ -0,0 +1,65 @@ +#!/usr/bin/with-contenv bash +# shellcheck shell=bash + +set -e + +function with_set_x() { + set -x + "$@" + { + ec=$? + set +x + return $ec + } 2>/dev/null +} + + +function log() { + echo "$*" >&2 +} + +log ">>> Setting up $(hostname) | id: $(id)\nIP:\n$(ip a)\nRoutes:\n$(ip r)\npyenv:\n$(pyenv versions)" + +echo " +AcceptEnv PYENV_VERSION +" >> /etc/ssh/sshd_config + +iface="$(ip route | awk '/default/ { print $5 }')" +default_gw="$(ip route | awk '/default/ { print $3 }')" +for addr in ${ADD_IP_ADDRESSES//,/ }; do + log ">>> Adding $addr to interface $iface" + net_addr=$(ipcalc -n "$addr" | awk -F= '{print $2}') + with_set_x ip addr add "$addr" dev "$iface" + with_set_x ip route add "$net_addr" via "$default_gw" dev "$iface" # so that sshuttle -N can discover routes +done + +log ">>> Starting iperf3 server" +iperf3 --server --port 5001 & + +mkdir -p /www +echo "
Hello from $(hostname)
+
+ip address
+$(ip address)
+ip route
+$(ip route)
+
" >/www/index.html +echo " +daemon off; +worker_processes 1; +error_log /dev/stdout info; +events { + worker_connections 1024; +} +http { + include /etc/nginx/mime.types; + server { + access_log /dev/stdout; + listen 8080 default_server; + listen [::]:8080 default_server; + root /www; + } +}" >/etc/nginx/nginx.conf + +log ">>> Starting nginx" +nginx & diff --git a/scripts/exec-sshuttle b/scripts/exec-sshuttle new file mode 100755 index 000000000..bd93495e1 --- /dev/null +++ b/scripts/exec-sshuttle @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -e + + export MSYS_NO_PATHCONV=1 + +function with_set_x() { + set -x + "$@" + { + ec=$? + set +x + return $ec + } 2>/dev/null +} + +function log() { + echo "$*" >&2 +} + +ssh_cmd='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null' +ssh_copy_id=false +args=() +subnet_args=() +while [[ $# -gt 0 ]]; do + arg=$1 + shift + case "$arg" in + -v|-vv*) + ssh_cmd+=" -v" + args+=("$arg") + ;; + -r) + args+=("-r" "$1") + shift + ;; + --copy-id) + ssh_copy_id=true + ;; + --server-py=*) + server_pyenv_ver="${arg#*=}" + ;; + --client-py=*) + client_pyenv_ver="${arg#*=}" + ;; + -6) + ipv6_only=true + ;; + --sshuttle-bin=*) + sshuttle_bin="${arg#*=}" + ;; + -N|*/*) + subnet_args+=("$arg") + ;; + -*) + args+=("$arg") + ;; + *) + if [[ -z "$target" ]]; then + target=$arg + else + args+=("$arg") + fi + ;; + esac +done +if [[ ${#subnet_args[@]} -eq 0 ]]; then + subnet_args=("-N") +fi + +if [[ $target == node-* ]]; then + log "Target is a a test-bed node" + port="2222" + user_part="test:test" + host=$("$(dirname "$0")/test-bed" get-ip "$target") + index=${target#node-} + if [[ $ipv6_only == true ]]; then + args+=("2001:0DB8::/112") + else + args+=("10.55.$index.0/24") + fi + target="$user_part@$host:$port" + if ! command -v sshpass >/dev/null; then + log "sshpass is not found. You might have to manually enter ssh password: 'test'" + fi + if [[ -z $server_pyenv_ver ]]; then + log "server-py argumwnt is not specified. Setting it to 3.8" + server_pyenv_ver="3.8" + fi +fi + +if [[ -n $server_pyenv_ver ]]; then + log "Would pass PYENV_VERRSION=$server_pyenv_ver to server. pyenv is required on server to make it work" + pycmd="/pyenv/shims/python" + ssh_cmd+=" -o SetEnv=PYENV_VERSION=${server_pyenv_ver:-'3'}" + args=("--python=$pycmd" "${args[@]}") +fi + +if [[ $ssh_copy_id == true ]]; then + log "Trying to make it passwordless" + if [[ $target == *@* ]]; then + user_part="${target%%@*}" + host_part="${target#*@}" + else + user_part="$(whoami)" + host_part="$target" + fi + if [[ $host_part == *:* ]]; then + host="${host_part%:*}" + port="${host_part#*:}" + else + host="$host_part" + port="22" + fi + if [[ $user_part == *:* ]]; then + user="${user_part%:*}" + password="${user_part#*:}" + else + user="$user_part" + password="" + fi + cmd=(ssh-copy-id -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p "$port" "$user@$host") + if [[ -n $password ]] && command -v sshpass >/dev/null; then + cmd=(sshpass -p "$password" "${cmd[@]}") + fi + with_set_x "${cmd[@]}" +fi + +if [[ -z $sshuttle_bin || "$sshuttle_bin" == dev ]]; then + cd "$(dirname "$0")/.." + export PYTHONPATH="." + if [[ -n $client_pyenv_ver ]]; then + log "Using pyenv version: $client_pyenv_ver" + command -v pyenv &>/dev/null || log "You have to install pyenv to use --client-py" && exit 1 + sshuttle_cmd=(/usr/bin/env PYENV_VERSION="$client_pyenv_ver" pyenv exec python -m sshuttle) + else + log "Using best python version availble" + if [ -x "$(command -v python3)" ] && + python3 -c "import sys; sys.exit(not sys.version_info > (3, 5))"; then + sshuttle_cmd=(python3 -m sshuttle) + else + sshuttle_cmd=(python -m sshuttle) + fi + fi +else + [[ -n $client_pyenv_ver ]] && log "Can't specify --client-py when --sshuttle-bin is specified" && exit 1 + sshuttle_cmd=("$sshuttle_bin") +fi + +if [[ " ${args[*]} " != *" --ssh-cmd "* ]]; then + args=("--ssh-cmd" "$ssh_cmd" "${args[@]}") +fi + +if [[ " ${args[*]} " != *" -r "* ]]; then + args=("-r" "$target" "${args[@]}") +fi + +set -x +"${sshuttle_cmd[@]}" --version +exec "${sshuttle_cmd[@]}" "${args[@]}" "${subnet_args[@]}" diff --git a/scripts/exec-tool b/scripts/exec-tool new file mode 100755 index 000000000..17ab51599 --- /dev/null +++ b/scripts/exec-tool @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -e + + +function with_set_x() { + set -x + "$@" + { + ec=$? + set +x + return $ec + } 2>/dev/null +} + +function log() { + echo "$*" >&2 +} + + +args=() +while [[ $# -gt 0 ]]; do + arg=$1 + shift + case "$arg" in + -6) + ipv6_only=true + continue + ;; + -*) ;; + *) + if [[ -z $tool ]]; then + tool=$arg + continue + elif [[ -z $node ]]; then + node=$arg + continue + fi + ;; + esac + args+=("$arg") +done + +tool=${tool?:"tool argument missing. should be one of iperf3,ping,curl,ab"} +node=${node?:"node argument missing. should be 'node-1' , 'node-2' etc"} + +if [[ $node == node-* ]]; then + index=${node#node-} + if [[ $ipv6_only == true ]]; then + host="2001:0DB8::55$index" + else + host="10.55.$index.77" + fi +else + host=$node +fi + +connect_timeout_sec=3 + +case "$tool" in +ping) + with_set_x exec ping -W $connect_timeout_sec "${args[@]}" "$host" + ;; +iperf3) + port=5001 + with_set_x exec iperf3 --client "$host" --port=$port --connect-timeout=$((connect_timeout_sec * 1000)) "${args[@]}" + ;; +curl) + port=8080 + if [[ $host = *:* ]]; then + host="[$host]" + args+=(--ipv6) + fi + with_set_x exec curl "http://$host:$port/" -v --connect-timeout $connect_timeout_sec "${args[@]}" + ;; +ab) + port=8080 + if [[ " ${args[*]}" != *" -n "* && " ${args[*]}" != *" -c "* ]]; then + args+=(-n 500 -c 50 "${args[@]}") + fi + with_set_x exec ab -s $connect_timeout_sec "${args[@]}" "http://$host:$port/" + ;; +*) + log "Unknown tool: $tool" + exit 2 + ;; +esac diff --git a/scripts/run-benchmark b/scripts/run-benchmark new file mode 100755 index 000000000..4f51b8a54 --- /dev/null +++ b/scripts/run-benchmark @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")" + +function with_set_x() { + set -x + "$@" + { + ec=$? + set +x + return $ec + } 2>/dev/null +} + +function log() { + echo "$*" >&2 +} + +./test-bed up -d + +benchmark() { + log -e "\n======== Benchmarking sshuttle | Args: [$*] ========" + local node=$1 + shift + with_set_x ./exec-sshuttle "$node" --listen 55771 "$@" & + sshuttle_pid=$! + trap 'kill -0 $sshuttle_pid &>/dev/null && kill -15 $sshuttle_pid' EXIT + while ! nc -z localhost 55771; do sleep 0.1; done + sleep 1 + ./exec-tool iperf3 "$node" --time=4 + with_set_x kill -15 $sshuttle_pid + wait $sshuttle_pid || true +} + +if [[ $# -gt 0 ]]; then + benchmark "${@}" +else + benchmark node-1 --sshuttle-bin="${SSHUTTLE_BIN:-sshuttle}" + benchmark node-1 --sshuttle-bin=dev +fi diff --git a/scripts/run-checks b/scripts/run-checks new file mode 100755 index 000000000..cdd518389 --- /dev/null +++ b/scripts/run-checks @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/.." + +export PYTHONPATH=. + +set -x +python -m flake8 sshuttle tests +python -m pytest . diff --git a/scripts/test-bed b/scripts/test-bed new file mode 100755 index 000000000..7877b9e0a --- /dev/null +++ b/scripts/test-bed @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")" + +if [[ -z $1 || $1 = -* ]]; then + set -- up "$@" +fi + +function with_set_x() { + set -x + "$@" + { + ec=$? + set +x + return $ec + } 2>/dev/null +} + +function build() { + # podman build -t ghcr.io/sshuttle/sshuttle-testbed . + with_set_x docker build --progress=plain -t ghcr.io/sshuttle/sshuttle-testbed -f Containerfile . +} + +function compose() { + # podman-compose "$@" + with_set_x docker compose "$@" +} + +function get-ip() { + local container_name=sshuttle-testbed-"$1" + docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$container_name" +} + +if [[ $1 == get-ip ]]; then + shift + get-ip "$@" +else + if [[ $* = *--build* ]]; then + build + fi + compose "$@" +fi diff --git a/setup.cfg b/setup.cfg index 8b398f06b..d4b09f3f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,17 +1,30 @@ +[bumpversion] +current_version = 1.3.1 + +[bumpversion:file:setup.py] + +[bumpversion:file:pyproject.toml] + +[bumpversion:file:sshuttle/version.py] + [aliases] -test=pytest +test = pytest [bdist_wheel] universal = 1 [upload] -sign=true -identity=0x1784577F811F6EAC +sign = true +identity = 0x1784577F811F6EAC [flake8] -count=true -show-source=true -statistics=true +count = true +show-source = true +statistics = true +max-line-length = 128 + +[pycodestyle] +max-line-length = 128 [tool:pytest] addopts = --cov=sshuttle --cov-branch --cov-report=term-missing diff --git a/setup.py b/setup.py deleted file mode 100755 index 54b751dc1..000000000 --- a/setup.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python - -# Copyright 2012-2014 Brian May -# -# This file is part of sshuttle. -# -# sshuttle is free software: you can redistribute it and/or modify -# it under the terms of the GNU Lesser General Public License as -# published by the Free Software Foundation; either version 2.1 of -# the License, or (at your option) any later version. -# -# sshuttle is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with sshuttle; If not, see . - -from setuptools import setup, find_packages - - -def version_scheme(version): - from setuptools_scm.version import guess_next_dev_version - version = guess_next_dev_version(version) - return version.lstrip("v") - - -setup( - name="sshuttle", - use_scm_version={ - 'write_to': "sshuttle/version.py", - 'version_scheme': version_scheme, - }, - setup_requires=['setuptools_scm'], - # version=version, - url='https://github.com/sshuttle/sshuttle', - author='Brian May', - author_email='brian@linuxpenguins.xyz', - description='Full-featured" VPN over an SSH tunnel', - packages=find_packages(), - license="LGPL2.1+", - long_description=open('README.rst').read(), - long_description_content_type="text/x-rst", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: " - "GNU Lesser General Public License v2 or later (LGPLv2+)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Topic :: System :: Networking", - ], - scripts=['bin/sudoers-add'], - entry_points={ - 'console_scripts': [ - 'sshuttle = sshuttle.cmdline:main', - ], - }, - python_requires='>=3.6', - install_requires=[ - ], - tests_require=[ - 'pytest', - 'pytest-cov', - 'pytest-runner', - 'flake8', - ], - keywords="ssh vpn", -) diff --git a/sshuttle/__init__.py b/sshuttle/__init__.py index a6ab7f4c8..9c73af26b 100644 --- a/sshuttle/__init__.py +++ b/sshuttle/__init__.py @@ -1,4 +1 @@ -try: - from sshuttle.version import version as __version__ -except ImportError: - __version__ = "unknown" +__version__ = "1.3.1" diff --git a/sshuttle/__main__.py b/sshuttle/__main__.py index b4bd42f66..3b4209334 100644 --- a/sshuttle/__main__.py +++ b/sshuttle/__main__.py @@ -1,4 +1,10 @@ """Coverage.py's main entry point.""" import sys +import os from sshuttle.cmdline import main -sys.exit(main()) +from sshuttle.helpers import debug3 + +debug3("Start: (pid=%s, ppid=%s) %r" % (os.getpid(), os.getppid(), sys.argv)) +exit_code = main() +debug3("Exit: (pid=%s, ppid=%s, code=%s) cmd %r" % (os.getpid(), os.getppid(), exit_code, sys.argv)) +sys.exit(exit_code) diff --git a/sshuttle/assembler.py b/sshuttle/assembler.py index 3cffdee97..e944280da 100644 --- a/sshuttle/assembler.py +++ b/sshuttle/assembler.py @@ -3,24 +3,27 @@ import types import platform -verbosity = verbosity # noqa: F821 must be a previously defined global +stdin = stdin # type: typing.BinaryIO # noqa: F821 must be a previously defined global +verbosity = verbosity # type: int # noqa: F821 must be a previously defined global if verbosity > 0: sys.stderr.write(' s: Running server on remote host with %s (version %s)\n' % (sys.executable, platform.python_version())) + z = zlib.decompressobj() + while 1: - name = sys.stdin.readline().strip() + name = stdin.readline().strip() if name: - # python2 compat: in python2 sys.stdin.readline().strip() -> str - # in python3 sys.stdin.readline().strip() -> bytes + # python2 compat: in python2 stdin.readline().strip() -> str + # in python3 stdin.readline().strip() -> bytes # (see #481) if sys.version_info >= (3, 0): name = name.decode("ASCII") - nbytes = int(sys.stdin.readline()) + nbytes = int(stdin.readline()) if verbosity >= 2: sys.stderr.write(' s: assembling %r (%d bytes)\n' % (name, nbytes)) - content = z.decompress(sys.stdin.read(nbytes)) + content = z.decompress(stdin.read(nbytes)) module = types.ModuleType(name) parents = name.rsplit(".", 1) @@ -44,6 +47,7 @@ import sshuttle.cmdline_options as options # noqa: E402 from sshuttle.server import main # noqa: E402 + main(options.latency_control, options.latency_buffer_size, options.auto_hosts, options.to_nameserver, options.auto_nets) diff --git a/sshuttle/client.py b/sshuttle/client.py index f9bf04bc0..08df6787a 100644 --- a/sshuttle/client.py +++ b/sshuttle/client.py @@ -5,6 +5,7 @@ import subprocess as ssubprocess import os import sys +import base64 import platform import sshuttle.helpers as helpers @@ -14,13 +15,17 @@ import sshuttle.sdnotify as sdnotify from sshuttle.ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper from sshuttle.helpers import log, debug1, debug2, debug3, Fatal, islocal, \ - resolvconf_nameservers, which + resolvconf_nameservers, which, is_admin_user, RWPair from sshuttle.methods import get_method, Features from sshuttle import __version__ try: from pwd import getpwnam except ImportError: getpwnam = None +try: + from grp import getgrnam +except ImportError: + getgrnam = None import socket @@ -123,14 +128,14 @@ def __init__(self, kind=socket.SOCK_STREAM, proto=0): self.bind_called = False def setsockopt(self, level, optname, value): - assert(self.bind_called) + assert self.bind_called if self.v6: self.v6.setsockopt(level, optname, value) if self.v4: self.v4.setsockopt(level, optname, value) def add_handler(self, handlers, callback, method, mux): - assert(self.bind_called) + assert self.bind_called socks = [] if self.v6: socks.append(self.v6) @@ -145,7 +150,7 @@ def add_handler(self, handlers, callback, method, mux): ) def listen(self, backlog): - assert(self.bind_called) + assert self.bind_called if self.v6: self.v6.listen(backlog) if self.v4: @@ -160,11 +165,26 @@ def listen(self, backlog): raise e def bind(self, address_v6, address_v4): - assert(not self.bind_called) + assert not self.bind_called self.bind_called = True if address_v6 is not None: self.v6 = socket.socket(socket.AF_INET6, self.type, self.proto) - self.v6.bind(address_v6) + try: + self.v6.bind(address_v6) + except OSError as e: + if e.errno == errno.EADDRNOTAVAIL: + # On an IPv6 Linux machine, this situation occurs + # if you run the following prior to running + # sshuttle: + # + # echo 1 > /proc/sys/net/ipv6/conf/all/disable_ipv6 + # echo 1 > /proc/sys/net/ipv6/conf/default/disable_ipv6 + raise Fatal("Could not bind to an IPv6 socket with " + "address %s and port %s. " + "Potential workaround: Run sshuttle " + "with '--disable-ipv6'." + % (str(address_v6[0]), str(address_v6[1]))) + raise e else: self.v6 = None if address_v4 is not None: @@ -174,7 +194,7 @@ def bind(self, address_v6, address_v4): self.v4 = None def print_listening(self, what): - assert(self.bind_called) + assert self.bind_called if self.v6: listenip = self.v6.getsockname() debug1('%s listening on %r.' % (what, listenip)) @@ -190,66 +210,187 @@ class FirewallClient: def __init__(self, method_name, sudo_pythonpath): self.auto_nets = [] - argvbase = ([sys.executable, sys.argv[0]] + + argv0 = sys.argv[0] + # argv0 is either be a normal Python file or an executable. + # After installed as a package, sshuttle command points to an .exe in Windows and Python shebang script elsewhere. + argvbase = (([sys.executable, sys.argv[0]] if argv0.endswith('.py') else [argv0]) + ['-v'] * (helpers.verbose or 0) + ['--method', method_name] + ['--firewall']) if ssyslog._p: argvbase += ['--syslog'] - # Determine how to prefix the command in order to elevate privileges. - if platform.platform().startswith('OpenBSD'): - elev_prefix = ['doas'] # OpenBSD uses built in `doas` + # A list of commands that we can try to run to start the firewall. + argv_tries = [] + + if is_admin_user(): # No need to elevate privileges + argv_tries.append(argvbase) else: - elev_prefix = ['sudo', '-p', '[local sudo] Password: '] - - # Look for binary and switch to absolute path if we can find - # it. - path = which(elev_prefix[0]) - if path: - elev_prefix[0] = path - - if sudo_pythonpath: - elev_prefix += ['/usr/bin/env', - 'PYTHONPATH=%s' % - os.path.dirname(os.path.dirname(__file__))] - argv_tries = [elev_prefix + argvbase, argvbase] - - # we can't use stdin/stdout=subprocess.PIPE here, as we normally would, - # because stupid Linux 'su' requires that stdin be attached to a tty. - # Instead, attach a *bidirectional* socket to its stdout, and use - # that for talking in both directions. - (s1, s2) = socket.socketpair() - - def setup(): - # run in the child process - s2.close() - if os.getuid() == 0: - argv_tries = argv_tries[-1:] # last entry only + if sys.platform == 'win32': + # runas_path = which("runas") + # if runas_path: + # argv_tries.append([runas_path , '/noprofile', '/user:Administrator', 'python']) + # XXX: Attempt to elevate privilege using 'runas' in windows seems not working. + # Because underlying ShellExecute() Windows api does not allow child process to inherit stdio. + # TODO(nom3ad): Try to implement another way to achieve this. + raise Fatal("Privilege elevation for Windows is not yet implemented. Please run from an administrator shell") + + # Linux typically uses sudo; OpenBSD uses doas. However, some + # Linux distributions are starting to use doas. + sudo_cmd = ['sudo', '-p', '[local sudo] Password: '] + doas_cmd = ['doas'] + + # For clarity, try to replace executable name with the + # full path. + doas_path = which("doas") + if doas_path: + doas_cmd[0] = doas_path + sudo_path = which("sudo") + if sudo_path: + sudo_cmd[0] = sudo_path + + # sudo_pythonpath indicates if we should set the + # PYTHONPATH environment variable when elevating + # privileges. This can be adjusted with the + # --no-sudo-pythonpath option. + if sudo_pythonpath: + pp_prefix = ['/usr/bin/env', + 'PYTHONPATH=%s' % + os.path.dirname(os.path.dirname(__file__))] + sudo_cmd = sudo_cmd + pp_prefix + doas_cmd = doas_cmd + pp_prefix + + # Final order should be: sudo/doas command, env + # pythonpath, and then argvbase (sshuttle command). + sudo_cmd = sudo_cmd + argvbase + doas_cmd = doas_cmd + argvbase + + # If we can find doas and not sudo or if we are on + # OpenBSD, try using doas first. + if (doas_path and not sudo_path) or platform.platform().startswith('OpenBSD'): + argv_tries = [doas_cmd, sudo_cmd, argvbase] + else: + argv_tries = [sudo_cmd, doas_cmd, argvbase] + + # Try all commands in argv_tries in order. If a command + # produces an error, try the next one. If command is + # successful, set 'success' variable and break. + success = False for argv in argv_tries: + + if sys.platform != 'win32': + # we can't use stdin/stdout=subprocess.PIPE here, as we + # normally would, because stupid Linux 'su' requires that + # stdin be attached to a tty. Instead, attach a + # *bidirectional* socket to its stdout, and use that for + # talking in both directions. + (s1, s2) = socket.socketpair() + pstdout = s1 + pstdin = s1 + penv = None + + def preexec_fn(): + # run in the child process + s2.close() + + def get_pfile(): + s1.close() + return s2.makefile('rwb') + + else: + # In Windows CPython, BSD sockets are not supported as subprocess stdio. + # if client (and firewall) processes is running as admin user, pipe based stdio can be used for communication. + # But if firewall process is spwaned in elevated mode by non-admin client process, access to stdio is lost. + # To work around this, we can use a socketpair. + # But socket need to be "shared" to child process as it can't be directly set as stdio. + can_use_stdio = is_admin_user() + + preexec_fn = None + penv = os.environ.copy() + if can_use_stdio: + pstdout = ssubprocess.PIPE + pstdin = ssubprocess.PIPE + + def get_pfile(): + return RWPair(self.p.stdout, self.p.stdin) + penv['SSHUTTLE_FW_COM_CHANNEL'] = 'stdio' + else: + pstdout = None + pstdin = None + (s1, s2) = socket.socketpair() + socket_share_data = s1.share(self.p.pid) + socket_share_data_b64 = base64.b64encode(socket_share_data) + penv['SSHUTTLE_FW_COM_CHANNEL'] = socket_share_data_b64 + + def get_pfile(): + s1.close() + return s2.makefile('rwb') try: - if argv[0] == 'su': - sys.stderr.write('[local su] ') - self.p = ssubprocess.Popen(argv, stdout=s1, preexec_fn=setup) + debug1("Starting firewall manager with command: %r" % argv) + self.p = ssubprocess.Popen(argv, stdout=pstdout, stdin=pstdin, env=penv, + preexec_fn=preexec_fn) # No env: Talking to `FirewallClient.start`, which has no i18n. - break except OSError as e: - log('Spawning firewall manager: %r' % argv) - raise Fatal(e) - self.argv = argv - s1.close() - self.pfile = s2.makefile('rwb') - line = self.pfile.readline() - self.check() - if line[0:5] != b'READY': - raise Fatal('%r expected READY, got %r' % (self.argv, line)) - method_name = line[6:-1] - self.method = get_method(method_name.decode("ASCII")) - self.method.set_firewall(self) + # This exception will occur if the program isn't + # present or isn't executable. + debug1('Unable to start firewall manager. Popen failed. ' + 'Command=%r Exception=%s' % (argv, e)) + continue + self.argv = argv + self.pfile = get_pfile() + + try: + line = self.pfile.readline() + except IOError: + # happens when firewall subprocess exists + line = '' + + rv = self.p.poll() # Check if process is still running + if rv: + # We might get here if program runs and exits before + # outputting anything. For example, someone might have + # entered the wrong password to elevate privileges. + debug1('Unable to start firewall manager. ' + 'Process exited too early. ' + '%r returned %d' % (self.argv, rv)) + continue + + # Normally, READY will be the first text on the first + # line. However, if an administrator replaced sudo with a + # shell script that echos a message to stdout and then + # runs sudo, READY won't be on the first line. To + # workaround this problem, we read a limited number of + # lines until we encounter "READY". Store all of the text + # we skipped in case we need it for an error message. + # + # A proper way to print a sudo warning message is to use + # sudo's lecture feature. sshuttle works correctly without + # this hack if sudo's lecture feature is used instead. + skipped_text = line + for i in range(100): + if line[0:5] == b'READY': + break + line = self.pfile.readline() + skipped_text += line + + if line[0:5] != b'READY': + debug1('Unable to start firewall manager. ' + 'Expected READY, got %r. ' + 'Command=%r' % (skipped_text, self.argv)) + continue + + method_name = line[6:-1] + self.method = get_method(method_name.decode("ASCII")) + self.method.set_firewall(self) + success = True + break + + if not success: + raise Fatal("All attempts to run firewall client process with elevated privileges were failed.") def setup(self, subnets_include, subnets_exclude, nslist, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, udp, - user, tmark): + user, group, tmark): self.subnets_include = subnets_include self.subnets_exclude = subnets_exclude self.nslist = nslist @@ -259,6 +400,7 @@ def setup(self, subnets_include, subnets_exclude, nslist, self.dnsport_v4 = dnsport_v4 self.udp = udp self.user = user + self.group = group self.tmark = tmark def check(self): @@ -297,9 +439,14 @@ def start(self): user = bytes(self.user, 'utf-8') else: user = b'%d' % self.user - - self.pfile.write(b'GO %d %s %s\n' % - (udp, user, bytes(self.tmark, 'ascii'))) + if self.group is None: + group = b'-' + elif isinstance(self.group, str): + group = bytes(self.group, 'utf-8') + else: + group = b'%d' % self.group + self.pfile.write(b'GO %d %s %s %s %d\n' % + (udp, user, group, bytes(self.tmark, 'ascii'), os.getpid())) self.pfile.flush() line = self.pfile.readline() @@ -308,8 +455,8 @@ def start(self): raise Fatal('%r expected STARTED, got %r' % (self.argv, line)) def sethostip(self, hostname, ip): - assert(not re.search(br'[^-\w\.]', hostname)) - assert(not re.search(br'[^0-9.]', ip)) + assert not re.search(br'[^-\w\.]', hostname) + assert not re.search(br'[^0-9.]', ip) self.pfile.write(b'HOST %s,%s\n' % (hostname, ip)) self.pfile.flush() @@ -444,7 +591,7 @@ def ondns(listener, method, mux, handlers): def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, python, latency_control, latency_buffer_size, dns_listener, seed_hosts, auto_hosts, auto_nets, daemon, - to_nameserver): + to_nameserver, add_cmd_delimiter, remote_shell): helpers.logprefix = 'c : ' debug1('Starting client with Python version %s' @@ -456,9 +603,11 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, debug1('Connecting to server...') try: - (serverproc, serversock) = ssh.connect( + (serverproc, rfile, wfile) = ssh.connect( ssh_cmd, remotename, python, stderr=ssyslog._p and ssyslog._p.stdin, + add_cmd_delimiter=add_cmd_delimiter, + remote_shell=remote_shell, options=dict(latency_control=latency_control, latency_buffer_size=latency_buffer_size, auto_hosts=auto_hosts, @@ -466,24 +615,25 @@ def _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, auto_nets=auto_nets)) except socket.error as e: if e.args[0] == errno.EPIPE: + debug3('Error: EPIPE: ' + repr(e)) raise Fatal("failed to establish ssh session (1)") else: raise - mux = Mux(serversock.makefile("rb"), serversock.makefile("wb")) + mux = Mux(rfile, wfile) handlers.append(mux) expected = b'SSHUTTLE0001' - try: v = 'x' while v and v != b'\0': - v = serversock.recv(1) + v = rfile.read(1) v = 'x' while v and v != b'\0': - v = serversock.recv(1) - initstring = serversock.recv(len(expected)) + v = rfile.read(1) + initstring = rfile.read(len(expected)) except socket.error as e: if e.args[0] == errno.ECONNRESET: + debug3('Error: ECONNRESET ' + repr(e)) raise Fatal("failed to establish ssh session (2)") else: raise @@ -660,7 +810,7 @@ def main(listenip_v6, listenip_v4, latency_buffer_size, dns, nslist, method_name, seed_hosts, auto_hosts, auto_nets, subnets_include, subnets_exclude, daemon, to_nameserver, pidfile, - user, sudo_pythonpath, tmark): + user, group, sudo_pythonpath, add_cmd_delimiter, remote_shell, tmark): if not remotename: raise Fatal("You must use -r/--remote to specify a remote " @@ -725,7 +875,8 @@ def main(listenip_v6, listenip_v4, # listenip_v4 contains user specified value or it is set to "auto". if listenip_v4 == "auto": - listenip_v4 = ('127.0.0.1', 0) + listenip_v4 = ('127.0.0.1' if avail.loopback_proxy_port else '0.0.0.0', 0) + debug1("Using default IPv4 listen address " + listenip_v4[0]) # listenip_v6 is... # None when IPv6 is disabled. @@ -735,8 +886,8 @@ def main(listenip_v6, listenip_v4, debug1("IPv6 disabled by --disable-ipv6") if listenip_v6 == "auto": if avail.ipv6: - debug1("IPv6 enabled: Using default IPv6 listen address ::1") - listenip_v6 = ('::1', 0) + listenip_v6 = ('::1' if avail.loopback_proxy_port else '::', 0) + debug1("IPv6 enabled: Using default IPv6 listen address " + listenip_v6[0]) else: debug1("IPv6 disabled since it isn't supported by method " "%s." % fw.method.name) @@ -763,6 +914,15 @@ def main(listenip_v6, listenip_v4, raise Fatal("User %s does not exist." % user) required.user = False if user is None else True + if group is not None: + if getgrnam is None: + raise Fatal("Routing by group not available on this system.") + try: + group = getgrnam(group).gr_gid + except KeyError: + raise Fatal("Group %s does not exist." % user) + required.group = False if group is None else True + if not required.ipv6 and len(subnets_v6) > 0: print("WARNING: IPv6 subnets were ignored because IPv6 is disabled " "in sshuttle.") @@ -907,7 +1067,7 @@ def feature_status(label, enabled, available): raise e if not bound: - assert(last_e) + assert last_e raise last_e tcp_listener.listen(10) tcp_listener.print_listening("TCP redirector") @@ -953,7 +1113,7 @@ def feature_status(label, enabled, available): dns_listener.print_listening("DNS") if not bound: - assert(last_e) + assert last_e raise last_e else: dnsport_v6 = 0 @@ -992,14 +1152,14 @@ def feature_status(label, enabled, available): # start the firewall fw.setup(subnets_include, subnets_exclude, nslist, redirectport_v6, redirectport_v4, dnsport_v6, dnsport_v4, - required.udp, user, tmark) + required.udp, user, group, tmark) # start the client process try: return _main(tcp_listener, udp_listener, fw, ssh_cmd, remotename, python, latency_control, latency_buffer_size, dns_listener, seed_hosts, auto_hosts, auto_nets, - daemon, to_nameserver) + daemon, to_nameserver, add_cmd_delimiter, remote_shell) finally: try: if daemon: diff --git a/sshuttle/cmdline.py b/sshuttle/cmdline.py index 2295d366b..94ee58272 100644 --- a/sshuttle/cmdline.py +++ b/sshuttle/cmdline.py @@ -1,6 +1,8 @@ +import os import re +import shlex import socket -import platform +import sys import sshuttle.helpers as helpers import sshuttle.client as client import sshuttle.firewall as firewall @@ -9,25 +11,21 @@ from sshuttle.options import parser, parse_ipport from sshuttle.helpers import family_ip_tuple, log, Fatal from sshuttle.sudoers import sudoers +from sshuttle.namespace import enter_namespace def main(): - opt = parser.parse_args() + if 'SSHUTTLE_ARGS' in os.environ: + env_args = shlex.split(os.environ['SSHUTTLE_ARGS']) + else: + env_args = [] + args = [*env_args, *sys.argv[1:]] - if opt.sudoers or opt.sudoers_no_modify: - if platform.platform().startswith('OpenBSD'): - log('Automatic sudoers does not work on BSD') - return 1 + opt = parser.parse_args(args) - if not opt.sudoers_filename: - log('--sudoers-file must be set or omitted.') - return 1 - - sudoers( - user_name=opt.sudoers_user, - no_modify=opt.sudoers_no_modify, - file_name=opt.sudoers_filename - ) + if opt.sudoers_no_modify: + # sudoers() calls exit() when it completes + sudoers(user_name=opt.sudoers_user) if opt.daemon: opt.syslog = 1 @@ -40,12 +38,23 @@ def main(): helpers.verbose = opt.verbose try: + # Since namespace and namespace-pid options are only available + # in linux, we must check if it exists with getattr + namespace = getattr(opt, 'namespace', None) + namespace_pid = getattr(opt, 'namespace_pid', None) + if namespace or namespace_pid: + prefix = helpers.logprefix + helpers.logprefix = 'ns: ' + enter_namespace(namespace, namespace_pid) + helpers.logprefix = prefix + if opt.firewall: if opt.subnets or opt.subnets_file: parser.error('exactly zero arguments expected') return firewall.main(opt.method, opt.syslog) elif opt.hostwatch: - return hostwatch.hw_main(opt.subnets, opt.auto_hosts) + hostwatch.hw_main(opt.subnets, opt.auto_hosts) + return 0 else: # parse_subnetports() is used to create a list of includes # and excludes. It is called once for each parameter and @@ -115,7 +124,10 @@ def main(): opt.to_ns, opt.pidfile, opt.user, + opt.group, opt.sudo_pythonpath, + opt.add_cmd_delimiter, + opt.remote_shell, opt.tmark) if return_code == 0: diff --git a/sshuttle/firewall.py b/sshuttle/firewall.py index d3806cdcc..0b34f6882 100644 --- a/sshuttle/firewall.py +++ b/sshuttle/firewall.py @@ -1,4 +1,5 @@ import errno +import shutil import socket import signal import sys @@ -6,13 +7,19 @@ import platform import traceback import subprocess as ssubprocess +import base64 +import io import sshuttle.ssyslog as ssyslog import sshuttle.helpers as helpers -from sshuttle.helpers import debug1, debug2, Fatal +from sshuttle.helpers import is_admin_user, log, debug1, debug2, debug3, Fatal from sshuttle.methods import get_auto_method, get_method -HOSTSFILE = '/etc/hosts' +if sys.platform == 'win32': + HOSTSFILE = r"C:\Windows\System32\drivers\etc\hosts" +else: + HOSTSFILE = '/etc/hosts' +sshuttle_pid = None def rewrite_etc_hosts(hostmap, port): @@ -29,7 +36,11 @@ def rewrite_etc_hosts(hostmap, port): else: raise if old_content.strip() and not os.path.exists(BAKFILE): - os.link(HOSTSFILE, BAKFILE) + try: + os.link(HOSTSFILE, BAKFILE) + except OSError: + # file is locked - performing non-atomic copy + shutil.copyfile(HOSTSFILE, BAKFILE) tmpname = "%s.%d.tmp" % (HOSTSFILE, port) f = open(tmpname, 'w') for line in old_content.rstrip().split('\n'): @@ -40,13 +51,20 @@ def rewrite_etc_hosts(hostmap, port): f.write('%-30s %s\n' % ('%s %s' % (ip, name), APPEND)) f.close() - if st is not None: - os.chown(tmpname, st.st_uid, st.st_gid) - os.chmod(tmpname, st.st_mode) - else: - os.chown(tmpname, 0, 0) - os.chmod(tmpname, 0o600) - os.rename(tmpname, HOSTSFILE) + if sys.platform != 'win32': + if st is not None: + os.chown(tmpname, st.st_uid, st.st_gid) + os.chmod(tmpname, st.st_mode) + else: + os.chown(tmpname, 0, 0) + os.chmod(tmpname, 0o644) + try: + os.rename(tmpname, HOSTSFILE) + except OSError: + # file is locked - performing non-atomic copy + log('Warning: Using a non-atomic way to overwrite %s that can corrupt the file if ' + 'multiple processes write to it simultaneously.' % HOSTSFILE) + shutil.move(tmpname, HOSTSFILE) def restore_etc_hosts(hostmap, port): @@ -56,35 +74,79 @@ def restore_etc_hosts(hostmap, port): rewrite_etc_hosts({}, port) -# Isolate function that needs to be replaced for tests -def setup_daemon(): - if os.getuid() != 0: - raise Fatal('You must be root (or enable su/sudo) to set the firewall') +def firewall_exit(signum, frame): + # The typical sshuttle exit is that the main sshuttle process + # exits, closes file descriptors it uses, and the firewall process + # notices that it can't read from stdin anymore and exits + # (cleaning up firewall rules). + # + # However, in some cases, Ctrl+C might get sent to the firewall + # process. This might caused if someone manually tries to kill the + # firewall process, or if sshuttle was started using sudo's use_pty option + # and they try to exit by pressing Ctrl+C. Here, we forward the + # Ctrl+C/SIGINT to the main sshuttle process which should trigger + # the typical exit process as described above. + global sshuttle_pid + if sshuttle_pid: + debug1("Relaying interupt signal to sshuttle process %d" % sshuttle_pid) + if sys.platform == 'win32': + sig = signal.CTRL_C_EVENT + else: + sig = signal.SIGINT + os.kill(sshuttle_pid, sig) + + +def _setup_daemon_for_unix_like(): + if not is_admin_user(): + raise Fatal('You must have root privileges (or enable su/sudo) to set the firewall') # don't disappear if our controlling terminal or stdout/stderr # disappears; we still have to clean up. signal.signal(signal.SIGHUP, signal.SIG_IGN) signal.signal(signal.SIGPIPE, signal.SIG_IGN) - signal.signal(signal.SIGTERM, signal.SIG_IGN) - signal.signal(signal.SIGINT, signal.SIG_IGN) - - # ctrl-c shouldn't be passed along to me. When the main sshuttle dies, - # I'll die automatically. + signal.signal(signal.SIGTERM, firewall_exit) + signal.signal(signal.SIGINT, firewall_exit) + + # Calling setsid() here isn't strictly necessary. However, it forces + # Ctrl+C to get sent to the main sshuttle process instead of to + # the firewall process---which is our preferred way to shutdown. + # Nonetheless, if the firewall process receives a SIGTERM/SIGINT + # signal, it will relay a SIGINT to the main sshuttle process + # automatically. try: os.setsid() except OSError: - raise Fatal("setsid() failed. This may occur if you are using sudo's " - "use_pty option. sshuttle does not currently work with " - "this option. An imperfect workaround: Run the sshuttle " - "command with sudo instead of running it as a regular " - "user and entering the sudo password when prompted.") + # setsid() fails if sudo is configured with the use_pty option. + pass + + return sys.stdin.buffer, sys.stdout.buffer + + +def _setup_daemon_for_windows(): + if not is_admin_user(): + raise Fatal('You must be administrator to set the firewall') + + signal.signal(signal.SIGTERM, firewall_exit) + signal.signal(signal.SIGINT, firewall_exit) + + com_chan = os.environ.get('SSHUTTLE_FW_COM_CHANNEL') + if com_chan == 'stdio': + debug3('Using inherited stdio for communicating with sshuttle client process') + else: + debug3('Using shared socket for communicating with sshuttle client process') + socket_share_data = base64.b64decode(com_chan) + sock = socket.fromshare(socket_share_data) # type: socket.socket + sys.stdin = io.TextIOWrapper(sock.makefile('rb', buffering=0)) + sys.stdout = io.TextIOWrapper(sock.makefile('wb', buffering=0), write_through=True) + sock.close() + return sys.stdin.buffer, sys.stdout.buffer - # because of limitations of the 'su' command, the *real* stdin/stdout - # are both attached to stdout initially. Clone stdout into stdin so we - # can read from it. - os.dup2(1, 0) - return sys.stdin, sys.stdout +# Isolate function that needs to be replaced for tests +if sys.platform == 'win32': + setup_daemon = _setup_daemon_for_windows +else: + setup_daemon = _setup_daemon_for_unix_like # Note that we're sorting in a very particular order: @@ -108,16 +170,24 @@ def flush_systemd_dns_cache(): # resolvectl in systemd 239. # https://github.com/systemd/systemd/blob/f8eb41003df1a4eab59ff9bec67b2787c9368dbd/NEWS#L3816 + p = None if helpers.which("resolvectl"): debug2("Flushing systemd's DNS resolver cache: " "resolvectl flush-caches") - ssubprocess.Popen(["resolvectl", "flush-caches"], - stdout=ssubprocess.PIPE, env=helpers.get_env()) + p = ssubprocess.Popen(["resolvectl", "flush-caches"], + stdout=ssubprocess.PIPE, env=helpers.get_env()) elif helpers.which("systemd-resolve"): debug2("Flushing systemd's DNS resolver cache: " "systemd-resolve --flush-caches") - ssubprocess.Popen(["systemd-resolve", "--flush-caches"], - stdout=ssubprocess.PIPE, env=helpers.get_env()) + p = ssubprocess.Popen(["systemd-resolve", "--flush-caches"], + stdout=ssubprocess.PIPE, env=helpers.get_env()) + + if p: + # Wait so flush is finished and process doesn't show up as defunct. + rv = p.wait() + if rv != 0: + log("Received non-zero return code %d when flushing DNS resolver " + "cache." % rv) # This is some voodoo for setting up the kernel's transparent @@ -150,29 +220,43 @@ def main(method_name, syslog): "PATH." % method_name) debug1('ready method name %s.' % method.name) - stdout.write('READY %s\n' % method.name) + stdout.write(('READY %s\n' % method.name).encode('ASCII')) stdout.flush() + def _read_next_string_line(): + try: + line = stdin.readline(128) + if not line: + return # parent probably exited + return line.decode('ASCII').strip() + except IOError as e: + # On windows, ConnectionResetError is thrown when parent process closes it's socket pair end + debug3('read from stdin failed: %s' % (e,)) + return # we wait until we get some input before creating the rules. That way, # sshuttle can launch us as early as possible (and get sudo password # authentication as early in the startup process as possible). - line = stdin.readline(128) - if not line: - return # parent died; nothing to do + try: + line = _read_next_string_line() + if not line: + return # parent probably exited + except IOError as e: + # On windows, ConnectionResetError is thrown when parent process closes it's socket pair end + debug3('read from stdin failed: %s' % (e,)) + return subnets = [] - if line != 'ROUTES\n': + if line != 'ROUTES': raise Fatal('expected ROUTES but got %r' % line) while 1: - line = stdin.readline(128) + line = _read_next_string_line() if not line: raise Fatal('expected route but got %r' % line) - elif line.startswith("NSLIST\n"): + elif line.startswith("NSLIST"): break try: - (family, width, exclude, ip, fport, lport) = \ - line.strip().split(',', 5) - except BaseException: + (family, width, exclude, ip, fport, lport) = line.split(',', 5) + except Exception: raise Fatal('expected route or NSLIST but got %r' % line) subnets.append(( int(family), @@ -184,17 +268,17 @@ def main(method_name, syslog): debug2('Got subnets: %r' % subnets) nslist = [] - if line != 'NSLIST\n': + if line != 'NSLIST': raise Fatal('expected NSLIST but got %r' % line) while 1: - line = stdin.readline(128) + line = _read_next_string_line() if not line: raise Fatal('expected nslist but got %r' % line) elif line.startswith("PORTS "): break try: - (family, ip) = line.strip().split(',', 1) - except BaseException: + (family, ip) = line.split(',', 1) + except Exception: raise Fatal('expected nslist or PORTS but got %r' % line) nslist.append((int(family), ip)) debug2('Got partial nslist: %r' % nslist) @@ -211,31 +295,33 @@ def main(method_name, syslog): dnsport_v6 = int(ports[2]) dnsport_v4 = int(ports[3]) - assert(port_v6 >= 0) - assert(port_v6 <= 65535) - assert(port_v4 >= 0) - assert(port_v4 <= 65535) - assert(dnsport_v6 >= 0) - assert(dnsport_v6 <= 65535) - assert(dnsport_v4 >= 0) - assert(dnsport_v4 <= 65535) + assert port_v6 >= 0 + assert port_v6 <= 65535 + assert port_v4 >= 0 + assert port_v4 <= 65535 + assert dnsport_v6 >= 0 + assert dnsport_v6 <= 65535 + assert dnsport_v4 >= 0 + assert dnsport_v4 <= 65535 debug2('Got ports: %d,%d,%d,%d' % (port_v6, port_v4, dnsport_v6, dnsport_v4)) - line = stdin.readline(128) - if not line: - raise Fatal('expected GO but got %r' % line) - elif not line.startswith("GO "): + line = _read_next_string_line() + if not line or not line.startswith("GO "): raise Fatal('expected GO but got %r' % line) _, _, args = line.partition(" ") - udp, user, tmark = args.strip().split(" ", 2) + global sshuttle_pid + udp, user, group, tmark, sshuttle_pid = args.split(" ", 4) udp = bool(int(udp)) + sshuttle_pid = int(sshuttle_pid) if user == '-': user = None - debug2('Got udp: %r, user: %r, tmark: %s' % - (udp, user, tmark)) + if group == '-': + group = None + debug2('Got udp: %r, user: %r, group: %r, tmark: %s, sshuttle_pid: %d' % + (udp, user, group, tmark, sshuttle_pid)) subnets_v6 = [i for i in subnets if i[0] == socket.AF_INET6] nslist_v6 = [i for i in nslist if i[0] == socket.AF_INET6] @@ -250,32 +336,41 @@ def main(method_name, syslog): method.setup_firewall( port_v6, dnsport_v6, nslist_v6, socket.AF_INET6, subnets_v6, udp, - user, tmark) + user, group, tmark) if subnets_v4 or nslist_v4: debug2('setting up IPv4.') method.setup_firewall( port_v4, dnsport_v4, nslist_v4, socket.AF_INET, subnets_v4, udp, - user, tmark) + user, group, tmark) - flush_systemd_dns_cache() - stdout.write('STARTED\n') + try: + # For some methods (eg: windivert) firewall setup will be differed / will run asynchronously. + # Such method implements wait_for_firewall_ready() to wait until firewall is up and running. + method.wait_for_firewall_ready(sshuttle_pid) + except NotImplementedError: + pass + + if sys.platform == 'linux': + flush_systemd_dns_cache() try: + stdout.write(b'STARTED\n') stdout.flush() - except IOError: - # the parent process died for some reason; he's surely been loud - # enough, so no reason to report another error + except IOError as e: # the parent process probably died + debug3('write to stdout failed: %s' % (e,)) return # Now we wait until EOF or any other kind of exception. We need # to stay running so that we don't need a *second* password # authentication at shutdown time - that cleanup is important! while 1: - line = stdin.readline(128) + line = _read_next_string_line() + if not line: + return if line.startswith('HOST '): - (name, ip) = line[5:].strip().split(',', 1) + (name, ip) = line[5:].split(',', 1) hostmap[name] = ip debug2('setting up /etc/hosts.') rewrite_etc_hosts(hostmap, port_v6 or port_v4) @@ -287,46 +382,47 @@ def main(method_name, syslog): finally: try: debug1('undoing changes.') - except BaseException: + except Exception: debug2('An error occurred, ignoring it.') try: if subnets_v6 or nslist_v6: debug2('undoing IPv6 changes.') - method.restore_firewall(port_v6, socket.AF_INET6, udp, user) - except BaseException: + method.restore_firewall(port_v6, socket.AF_INET6, udp, user, group) + except Exception: try: debug1("Error trying to undo IPv6 firewall.") debug1(traceback.format_exc()) - except BaseException: + except Exception: debug2('An error occurred, ignoring it.') try: if subnets_v4 or nslist_v4: debug2('undoing IPv4 changes.') - method.restore_firewall(port_v4, socket.AF_INET, udp, user) - except BaseException: + method.restore_firewall(port_v4, socket.AF_INET, udp, user, group) + except Exception: try: debug1("Error trying to undo IPv4 firewall.") debug1(traceback.format_exc()) - except BaseException: + except Exception: debug2('An error occurred, ignoring it.') try: # debug2() message printed in restore_etc_hosts() function. restore_etc_hosts(hostmap, port_v6 or port_v4) - except BaseException: + except Exception: try: debug1("Error trying to undo /etc/hosts changes.") debug1(traceback.format_exc()) - except BaseException: + except Exception: debug2('An error occurred, ignoring it.') - try: - flush_systemd_dns_cache() - except BaseException: + if sys.platform == 'linux': try: - debug1("Error trying to flush systemd dns cache.") - debug1(traceback.format_exc()) - except BaseException: - debug2("An error occurred, ignoring it.") + flush_systemd_dns_cache() + except Exception: + try: + debug1("Error trying to flush systemd dns cache.") + debug1(traceback.format_exc()) + except Exception: + debug2("An error occurred, ignoring it.") diff --git a/sshuttle/helpers.py b/sshuttle/helpers.py index 372feb32a..2c989400e 100644 --- a/sshuttle/helpers.py +++ b/sshuttle/helpers.py @@ -2,6 +2,13 @@ import socket import errno import os +import threading +import subprocess +import traceback +import re + +if sys.platform != "win32": + import fcntl logprefix = '' verbose = 0 @@ -11,24 +18,28 @@ def b(s): return s.encode("ASCII") +def get_verbose_level(): + return verbose + + def log(s): global logprefix try: sys.stdout.flush() + except (IOError, ValueError): # ValueError ~ I/O operation on closed file + pass + try: # Put newline at end of string if line doesn't have one. if not s.endswith("\n"): s = s+"\n" - # Allow multi-line messages - if s.find("\n") != -1: - prefix = logprefix - s = s.rstrip("\n") - for line in s.split("\n"): - sys.stderr.write(prefix + line + "\n") - prefix = " " - else: - sys.stderr.write(logprefix + s) + + prefix = logprefix + s = s.rstrip("\n") + for line in s.split("\n"): + sys.stderr.write(prefix + line + "\n") + prefix = " " sys.stderr.flush() - except IOError: + except (IOError, ValueError): # ValueError ~ I/O operation on closed file # this could happen if stderr gets forcibly disconnected, eg. because # our tty closes. That sucks, but it's no reason to abort the program. pass @@ -105,18 +116,43 @@ def resolvconf_nameservers(systemd_resolved): return nsservers -def resolvconf_random_nameserver(systemd_resolved): +def windows_nameservers(): + out = subprocess.check_output(["powershell", "-NonInteractive", "-NoProfile", "-Command", "Get-DnsClientServerAddress"], + encoding="utf-8") + servers = set() + for line in out.splitlines(): + if line.startswith("Loopback "): + continue + m = re.search(r'{.+}', line) + if not m: + continue + for s in m.group().strip('{}').split(','): + s = s.strip() + if s.startswith('fec0:0:0:ffff'): + continue + servers.add(s) + debug2("Found DNS servers: %s" % servers) + return [(socket.AF_INET6 if ':' in s else socket.AF_INET, s) for s in servers] + + +def get_random_nameserver(): """Return a random nameserver selected from servers produced by - resolvconf_nameservers(). See documentation for - resolvconf_nameservers() for a description of the parameter. + resolvconf_nameservers()/windows_nameservers() """ - lines = resolvconf_nameservers(systemd_resolved) - if lines: - if len(lines) > 1: + if sys.platform == "win32": + if globals().get('_nameservers') is None: + ns_list = windows_nameservers() + globals()['_nameservers'] = ns_list + else: + ns_list = globals()['_nameservers'] + else: + ns_list = resolvconf_nameservers(systemd_resolved=False) + if ns_list: + if len(ns_list) > 1: # don't import this unless we really need it import random - random.shuffle(lines) - return lines[0] + random.shuffle(ns_list) + return ns_list[0] else: return (socket.AF_INET, '127.0.0.1') @@ -223,3 +259,91 @@ def which(file, mode=os.F_OK | os.X_OK): else: debug2("which() could not find '%s' in %s" % (file, path)) return rv + + +def is_admin_user(): + if sys.platform == 'win32': + # https://stackoverflow.com/questions/130763/request-uac-elevation-from-within-a-python-script/41930586#41930586 + import ctypes + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except Exception: + return False + + # TODO(nom3ad): for sys.platform == 'linux', check capabilities for non-root users. (CAP_NET_ADMIN might be enough?) + return os.getuid() == 0 + + +def set_non_blocking_io(fd): + if sys.platform != "win32": + try: + os.set_blocking(fd, False) + except AttributeError: + # python < 3.5 + flags = fcntl.fcntl(fd, fcntl.F_GETFL) + flags |= os.O_NONBLOCK + fcntl.fcntl(fd, fcntl.F_SETFL, flags) + else: + _sock = socket.fromfd(fd, socket.AF_INET, socket.SOCK_STREAM) + _sock.setblocking(False) + + +class RWPair: + def __init__(self, r, w): + self.r = r + self.w = w + self.read = r.read + self.readline = r.readline + self.write = w.write + self.flush = w.flush + + def close(self): + for f in self.r, self.w: + try: + f.close() + except Exception: + pass + + +class SocketRWShim: + __slots__ = ('_r', '_w', '_on_end', '_s1', '_s2', '_t1', '_t2') + + def __init__(self, r, w, on_end=None): + self._r = r + self._w = w + self._on_end = on_end + + self._s1, self._s2 = socket.socketpair() + debug3("[SocketShim] r=%r w=%r | s1=%r s2=%r" % (self._r, self._w, self._s1, self._s2)) + + def stream_reader_to_sock(): + try: + for data in iter(lambda: self._r.read(16384), b''): + self._s1.sendall(data) + # debug3("[SocketRWShim] <<<<< r.read() %d %r..." % (len(data), data[:min(32, len(data))])) + except Exception: + traceback.print_exc(file=sys.stderr) + finally: + debug2("[SocketRWShim] Thread 'stream_reader_to_sock' exiting") + self._s1.close() + self._on_end and self._on_end() + + def stream_sock_to_writer(): + try: + for data in iter(lambda: self._s1.recv(16384), b''): + while data: + n = self._w.write(data) + data = data[n:] + # debug3("[SocketRWShim] <<<<< w.write() %d %r..." % (len(data), data[:min(32, len(data))])) + except Exception: + traceback.print_exc(file=sys.stderr) + finally: + debug2("[SocketRWShim] Thread 'stream_sock_to_writer' exiting") + self._s1.close() + self._on_end and self._on_end() + + self._t1 = threading.Thread(target=stream_reader_to_sock, name='stream_reader_to_sock', daemon=True).start() + self._t2 = threading.Thread(target=stream_sock_to_writer, name='stream_sock_to_writer', daemon=True).start() + + def makefiles(self): + return self._s2.makefile("rb", buffering=0), self._s2.makefile("wb", buffering=0) diff --git a/sshuttle/hostwatch.py b/sshuttle/hostwatch.py index a016f4f4a..1884165ca 100644 --- a/sshuttle/hostwatch.py +++ b/sshuttle/hostwatch.py @@ -18,6 +18,8 @@ # Have we already failed to write CACHEFILE? CACHE_WRITE_FAILED = False +SHOULD_WRITE_CACHE = False + hostnames = {} queue = {} try: @@ -55,7 +57,7 @@ def write_host_cache(): try: os.unlink(tmpname) - except BaseException: + except Exception: pass @@ -81,6 +83,11 @@ def read_host_cache(): ip = re.sub(r'[^0-9.]', '', ip).strip() if name and ip: found_host(name, ip) + f.close() + global SHOULD_WRITE_CACHE + if SHOULD_WRITE_CACHE: + write_host_cache() + SHOULD_WRITE_CACHE = False def found_host(name, ip): @@ -97,12 +104,13 @@ def found_host(name, ip): if hostname != name: found_host(hostname, ip) + global SHOULD_WRITE_CACHE oldip = hostnames.get(name) if oldip != ip: hostnames[name] = ip debug1('Found: %s: %s' % (name, ip)) sys.stdout.write('%s,%s\n' % (name, ip)) - write_host_cache() + SHOULD_WRITE_CACHE = True def _check_etc_hosts(): diff --git a/sshuttle/linux.py b/sshuttle/linux.py index 5055fc03d..ea5f954c2 100644 --- a/sshuttle/linux.py +++ b/sshuttle/linux.py @@ -20,7 +20,7 @@ def ipt_chain_exists(family, table, name): argv = [cmd, '-w', '-t', table, '-nL'] try: output = ssubprocess.check_output(argv, env=get_env()) - for line in output.decode('ASCII').split('\n'): + for line in output.decode('ASCII', errors='replace').split('\n'): if line.startswith('Chain %s ' % name): return True except ssubprocess.CalledProcessError as e: diff --git a/sshuttle/methods/__init__.py b/sshuttle/methods/__init__.py index f8a77a941..0f56e59ae 100644 --- a/sshuttle/methods/__init__.py +++ b/sshuttle/methods/__init__.py @@ -1,14 +1,13 @@ import importlib import socket import struct +import sys import errno import ipaddress from sshuttle.helpers import Fatal, debug3 def original_dst(sock): - ip = "0.0.0.0" - port = -1 try: family = sock.family SO_ORIGINAL_DST = 80 @@ -47,11 +46,13 @@ def set_firewall(self, firewall): @staticmethod def get_supported_features(): result = Features() + result.loopback_proxy_port = True result.ipv4 = True result.ipv6 = False result.udp = False result.dns = True result.user = False + result.group = False return result @staticmethod @@ -72,8 +73,8 @@ def recv_udp(udp_listener, bufsize): def send_udp(self, sock, srcip, dstip, data): if srcip is not None: - Fatal("Method %s send_udp does not support setting srcip to %r" - % (self.name, srcip)) + raise Fatal("Method %s send_udp does not support setting srcip to %r" + % (self.name, srcip)) sock.sendto(data, dstip) def setup_tcp_listener(self, tcp_listener): @@ -91,10 +92,13 @@ def assert_features(self, features): (key, self.name)) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): raise NotImplementedError() - def restore_firewall(self, port, family, udp, user): + def restore_firewall(self, port, family, udp, user, group): + raise NotImplementedError() + + def wait_for_firewall_ready(self, sshuttle_pid): raise NotImplementedError() @staticmethod @@ -110,7 +114,7 @@ def get_method(method_name): def get_auto_method(): debug3("Selecting a method automatically...") # Try these methods, in order: - methods_to_try = ["nat", "nft", "pf", "ipfw"] + methods_to_try = ["nat", "nft", "pf", "ipfw"] if sys.platform != "win32" else ["windivert"] for m in methods_to_try: method = get_method(m) if method.is_supported(): diff --git a/sshuttle/methods/ipfw.py b/sshuttle/methods/ipfw.py index 1a31e025e..053ddf37b 100644 --- a/sshuttle/methods/ipfw.py +++ b/sshuttle/methods/ipfw.py @@ -52,7 +52,7 @@ def _fill_oldctls(prefix): p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) for line in p.stdout: line = line.decode() - assert(line[-1] == '\n') + assert line[-1] == '\n' (k, v) = line[:-1].split(': ', 1) _oldctls[k] = v.strip() rv = p.wait() @@ -74,7 +74,7 @@ def _sysctl_set(name, val): def sysctl_set(name, val, permanent=False): PREFIX = 'net.inet.ip' - assert(name.startswith(PREFIX + '.')) + assert name.startswith(PREFIX + '.') val = str(val) if not _oldctls: _fill_oldctls(PREFIX) @@ -156,7 +156,7 @@ def setup_udp_listener(self, udp_listener): # udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVDSTADDR, 1) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): # IPv6 not supported if family not in [socket.AF_INET]: raise Exception( @@ -207,7 +207,7 @@ def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, else: ipfw('table', '126', 'add', '%s/%s' % (snet, swidth)) - def restore_firewall(self, port, family, udp, user): + def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET]: raise Exception( 'Address family "%s" unsupported by ipfw method' diff --git a/sshuttle/methods/nat.py b/sshuttle/methods/nat.py index 076d880d3..4da1a8354 100644 --- a/sshuttle/methods/nat.py +++ b/sshuttle/methods/nat.py @@ -13,7 +13,7 @@ class Method(BaseMethod): # recently-started one will win (because we use "-I OUTPUT 1" instead of # "-A OUTPUT"). def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( 'Address family "%s" unsupported by nat method_name' @@ -31,13 +31,18 @@ def _ipm(*args): chain = 'sshuttle-%s' % port # basic cleanup/setup of chains - self.restore_firewall(port, family, udp, user) + self.restore_firewall(port, family, udp, user, group) _ipt('-N', chain) _ipt('-F', chain) - if user is not None: - _ipm('-I', 'OUTPUT', '1', '-m', 'owner', '--uid-owner', str(user), - '-j', 'MARK', '--set-mark', str(port)) + if user is not None or group is not None: + margs = ['-I', 'OUTPUT', '1', '-m', 'owner'] + if user is not None: + margs += ['--uid-owner', str(user)] + if group is not None: + margs += ['--gid-owner', str(group)] + margs += ['-j', 'MARK', '--set-mark', str(port)] + nonfatal(_ipm, *margs) args = '-m', 'mark', '--mark', str(port), '-j', chain else: args = '-j', chain @@ -54,11 +59,6 @@ def _ipm(*args): '--dport', '53', '--to-ports', str(dnsport)) - # Don't route any remaining local traffic through sshuttle. - _ipt('-A', chain, '-j', 'RETURN', - '-m', 'addrtype', - '--dst-type', 'LOCAL') - # create new subnet entries. for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight, reverse=True): @@ -75,7 +75,12 @@ def _ipm(*args): '--dest', '%s/%s' % (snet, swidth), *(tcp_ports + ('--to-ports', str(port)))) - def restore_firewall(self, port, family, udp, user): + # Don't route any remaining local traffic through sshuttle. + _ipt('-A', chain, '-j', 'RETURN', + '-m', 'addrtype', + '--dst-type', 'LOCAL') + + def restore_firewall(self, port, family, udp, user, group): # only ipv4 supported with NAT if family != socket.AF_INET and family != socket.AF_INET6: raise Exception( @@ -96,9 +101,15 @@ def _ipm(*args): # basic cleanup/setup of chains if ipt_chain_exists(family, table, chain): - if user is not None: - nonfatal(_ipm, '-D', 'OUTPUT', '-m', 'owner', '--uid-owner', - str(user), '-j', 'MARK', '--set-mark', str(port)) + if user is not None or group is not None: + margs = ['-D', 'OUTPUT', '-m', 'owner'] + if user is not None: + margs += ['--uid-owner', str(user)] + if group is not None: + margs += ['--gid-owner', str(group)] + margs += ['-j', 'MARK', '--set-mark', str(port)] + nonfatal(_ipm, *margs) + args = '-m', 'mark', '--mark', str(port), '-j', chain else: args = '-j', chain @@ -111,6 +122,7 @@ def get_supported_features(self): result = super(Method, self).get_supported_features() result.user = True result.ipv6 = True + result.group = True return result def is_supported(self): diff --git a/sshuttle/methods/nft.py b/sshuttle/methods/nft.py index 64ab3a6d8..59b6310d8 100644 --- a/sshuttle/methods/nft.py +++ b/sshuttle/methods/nft.py @@ -13,7 +13,7 @@ class Method(BaseMethod): # recently-started one will win (because we use "-I OUTPUT 1" instead of # "-A OUTPUT"). def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): if udp: raise Exception("UDP not supported by nft") @@ -87,7 +87,7 @@ def _nft(action, *args): ip_version, 'daddr %s/%s' % (snet, swidth), ('redirect to :' + str(port))))) - def restore_firewall(self, port, family, udp, user): + def restore_firewall(self, port, family, udp, user, group): if udp: raise Exception("UDP not supported by nft method_name") diff --git a/sshuttle/methods/pf.py b/sshuttle/methods/pf.py index ed56c514a..2d679785c 100644 --- a/sshuttle/methods/pf.py +++ b/sshuttle/methods/pf.py @@ -266,7 +266,7 @@ class pfioc_natlook(Structure): ("proto_variant", c_uint8), ("direction", c_uint8)] - self.pfioc_rule = c_char * 3424 + self.pfioc_rule = c_char * 3408 self.pfioc_natlook = pfioc_natlook super(OpenBsd, self).__init__() @@ -448,7 +448,7 @@ def get_tcp_dstip(self, sock): return sock.getsockname() def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by pf method_name' @@ -473,7 +473,7 @@ def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, pf.add_rules(anchor, includes, port, dnsport, nslist, family) pf.enable() - def restore_firewall(self, port, family, udp, user): + def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by pf method_name' diff --git a/sshuttle/methods/tproxy.py b/sshuttle/methods/tproxy.py index 0bd15a1bb..84eea3ff0 100644 --- a/sshuttle/methods/tproxy.py +++ b/sshuttle/methods/tproxy.py @@ -6,6 +6,7 @@ from sshuttle.helpers import debug1, debug2, debug3, Fatal, which import socket +import os IP_TRANSPARENT = 19 @@ -69,6 +70,15 @@ def recv_udp(self, udp_listener, bufsize): return None return srcip, dstip, data + def setsockopt_error(self, e): + """The tproxy method needs root permissions to successfully + set the IP_TRANSPARENT option on sockets. This method is + called when we receive a PermissionError when trying to do + so.""" + raise Fatal("Insufficient permissions for tproxy method.\n" + "Your effective UID is %d, not 0. Try rerunning as root.\n" + % os.geteuid()) + def send_udp(self, sock, srcip, dstip, data): if not srcip: debug1( @@ -77,16 +87,26 @@ def send_udp(self, sock, srcip, dstip, data): return sender = socket.socket(sock.family, socket.SOCK_DGRAM) sender.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sender.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + try: + sender.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + except PermissionError as e: + self.setsockopt_error(e) sender.bind(srcip) sender.sendto(data, dstip) sender.close() def setup_tcp_listener(self, tcp_listener): - tcp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + try: + tcp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + except PermissionError as e: + self.setsockopt_error(e) def setup_udp_listener(self, udp_listener): - udp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + try: + udp_listener.setsockopt(socket.SOL_IP, IP_TRANSPARENT, 1) + except PermissionError as e: + self.setsockopt_error(e) + if udp_listener.v4 is not None: udp_listener.v4.setsockopt( socket.SOL_IP, IP_RECVORIGDSTADDR, 1) @@ -94,7 +114,7 @@ def setup_udp_listener(self, udp_listener): udp_listener.v6.setsockopt(SOL_IPV6, IPV6_RECVORIGDSTADDR, 1) def setup_firewall(self, port, dnsport, nslist, family, subnets, udp, - user, tmark): + user, group, tmark): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by tproxy method' @@ -107,14 +127,14 @@ def _ipt(*args): def _ipt_proto_ports(proto, fport, lport): return proto + ('--dport', '%d:%d' % (fport, lport)) \ - if fport else proto + if fport else proto mark_chain = 'sshuttle-m-%s' % port tproxy_chain = 'sshuttle-t-%s' % port divert_chain = 'sshuttle-d-%s' % port # basic cleanup/setup of chains - self.restore_firewall(port, family, udp, user) + self.restore_firewall(port, family, udp, user, group) _ipt('-N', mark_chain) _ipt('-F', mark_chain) @@ -125,8 +145,18 @@ def _ipt_proto_ports(proto, fport, lport): _ipt('-I', 'OUTPUT', '1', '-j', mark_chain) _ipt('-I', 'PREROUTING', '1', '-j', tproxy_chain) + for _, ip in [i for i in nslist if i[0] == family]: + _ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', tmark, + '--dest', '%s/32' % ip, + '-m', 'udp', '-p', 'udp', '--dport', '53') + _ipt('-A', tproxy_chain, '-j', 'TPROXY', + '--tproxy-mark', tmark, + '--dest', '%s/32' % ip, + '-m', 'udp', '-p', 'udp', '--dport', '53', + '--on-port', str(dnsport)) + # Don't have packets sent to any of our local IP addresses go - # through the tproxy or mark chains. + # through the tproxy or mark chains (except DNS ones). # # Without this fix, if a large subnet is redirected through # sshuttle (i.e., 0/0), then the user may be unable to receive @@ -149,16 +179,6 @@ def _ipt_proto_ports(proto, fport, lport): _ipt('-A', tproxy_chain, '-m', 'socket', '-j', divert_chain, '-m', 'udp', '-p', 'udp') - for _, ip in [i for i in nslist if i[0] == family]: - _ipt('-A', mark_chain, '-j', 'MARK', '--set-mark', tmark, - '--dest', '%s/32' % ip, - '-m', 'udp', '-p', 'udp', '--dport', '53') - _ipt('-A', tproxy_chain, '-j', 'TPROXY', - '--tproxy-mark', tmark, - '--dest', '%s/32' % ip, - '-m', 'udp', '-p', 'udp', '--dport', '53', - '--on-port', str(dnsport)) - for _, swidth, sexclude, snet, fport, lport \ in sorted(subnets, key=subnet_weight, reverse=True): tcp_ports = ('-p', 'tcp') @@ -208,7 +228,7 @@ def _ipt_proto_ports(proto, fport, lport): '-m', 'udp', *(udp_ports + ('--on-port', str(port)))) - def restore_firewall(self, port, family, udp, user): + def restore_firewall(self, port, family, udp, user, group): if family not in [socket.AF_INET, socket.AF_INET6]: raise Exception( 'Address family "%s" unsupported by tproxy method' diff --git a/sshuttle/methods/windivert.py b/sshuttle/methods/windivert.py new file mode 100644 index 000000000..e6603e369 --- /dev/null +++ b/sshuttle/methods/windivert.py @@ -0,0 +1,533 @@ +import os +import sys +from ipaddress import ip_address, ip_network +import threading +from collections import namedtuple +import socket +import subprocess +import re +from multiprocessing import shared_memory +from struct import Struct +from functools import wraps +from enum import IntEnum +import time +import traceback + + +from sshuttle.methods import BaseMethod +from sshuttle.helpers import log, debug3, debug1, debug2, get_verbose_level, Fatal + +try: + # https://reqrypt.org/windivert-doc.html#divert_iphdr + # https://www.reqrypt.org/windivert-changelog.txt + import pydivert +except ImportError: + raise Exception("Could not import pydivert module. windivert requires https://pypi.org/project/pydivert") + + +ConnectionTuple = namedtuple( + "ConnectionTuple", + ["protocol", "ip_version", "src_addr", "src_port", "dst_addr", "dst_port", "state_epoch", "state"], +) + + +WINDIVERT_MAX_CONNECTIONS = int(os.environ.get('WINDIVERT_MAX_CONNECTIONS', 1024)) + + +class IPProtocol(IntEnum): + TCP = socket.IPPROTO_TCP + UDP = socket.IPPROTO_UDP + + @property + def filter(self): + return "tcp" if self == IPProtocol.TCP else "udp" + + +class IPFamily(IntEnum): + IPv4 = socket.AF_INET + IPv6 = socket.AF_INET6 + + @staticmethod + def from_ip_version(version): + return IPFamily.IPv6 if version == 4 else IPFamily.IPv4 + + @property + def filter(self): + return "ip" if self == socket.AF_INET else "ipv6" + + @property + def version(self): + return 4 if self == socket.AF_INET else 6 + + @property + def loopback_addr(self): + return ip_address("127.0.0.1" if self == socket.AF_INET else "::1") + + +class ConnState(IntEnum): + TCP_SYN_SENT = 11 # SYN sent + TCP_ESTABLISHED = 12 # SYN+ACK received + TCP_FIN_WAIT_1 = 91 # FIN sent + TCP_CLOSE_WAIT = 92 # FIN received + + @staticmethod + def can_timeout(state): + return state in (ConnState.TCP_SYN_SENT, ConnState.TCP_FIN_WAIT_1, ConnState.TCP_CLOSE_WAIT) + + +def repr_pkt(p): + try: + direction = p.direction.name + if p.is_loopback: + direction += "/lo" + except AttributeError: # windiver > 2.0 + direction = 'OUT' if p.address.Outbound == 1 else 'IN' + if p.address.Loopback == 1: + direction += '/lo' + r = f"{direction} {p.src_addr}:{p.src_port}->{p.dst_addr}:{p.dst_port}" + if p.tcp: + t = p.tcp + r += f" {len(t.payload)}B (" + r += "+".join( + f.upper() for f in ("fin", "syn", "rst", "psh", "ack", "urg", "ece", "cwr", "ns") if getattr(t, f) + ) + r += f") SEQ#{t.seq_num}" + if t.ack: + r += f" ACK#{t.ack_num}" + r += f" WZ={t.window_size}" + else: + r += f" {p.udp=} {p.icmpv4=} {p.icmpv6=}" + return f"" + + +def synchronized_method(lock): + def decorator(method): + @wraps(method) + def wrapped(self, *args, **kwargs): + with getattr(self, lock): + return method(self, *args, **kwargs) + + return wrapped + + return decorator + + +class ConnTrack: + + _instance = None + + def __new__(cls, *args, **kwargs): + if not cls._instance: + cls._instance = object.__new__(cls) + return cls._instance + raise RuntimeError("ConnTrack can not be instantiated multiple times") + + def __init__(self, name, max_connections=0) -> None: + self.struct_full_tuple = Struct(">" + "".join(("B", "B", "16s", "H", "16s", "H", "L", "B"))) + self.struct_src_tuple = Struct(">" + "".join(("B", "B", "16s", "H"))) + self.struct_state_tuple = Struct(">" + "".join(("L", "B"))) + + try: + self.max_connections = max_connections + self.shm_list = shared_memory.ShareableList( + [bytes(self.struct_full_tuple.size) for _ in range(max_connections)], name=name + ) + self.is_owner = True + self.next_slot = 0 + self.used_slots = set() + self.rlock = threading.RLock() + except FileExistsError: + self.is_owner = False + self.shm_list = shared_memory.ShareableList(name=name) + self.max_connections = len(self.shm_list) + + debug2( + f"ConnTrack: is_owner={self.is_owner} cap={len(self.shm_list)} item_sz={self.struct_full_tuple.size}B" + f"shm_name={self.shm_list.shm.name} shm_sz={self.shm_list.shm.size}B" + ) + + @synchronized_method("rlock") + def add(self, proto, src_addr, src_port, dst_addr, dst_port, state): + if not self.is_owner: + raise RuntimeError("Only owner can mutate ConnTrack") + if len(self.used_slots) >= self.max_connections: + raise RuntimeError(f"No slot available in ConnTrack {len(self.used_slots)}/{self.max_connections}") + + if self.get(proto, src_addr, src_port): + return + + for _ in range(self.max_connections): + if self.next_slot not in self.used_slots: + break + self.next_slot = (self.next_slot + 1) % self.max_connections + else: + raise RuntimeError("No slot available in ConnTrack") # should not be here + + src_addr = ip_address(src_addr) + dst_addr = ip_address(dst_addr) + assert src_addr.version == dst_addr.version + ip_version = src_addr.version + state_epoch = int(time.time()) + entry = (proto, ip_version, src_addr.packed, src_port, dst_addr.packed, dst_port, state_epoch, state) + packed = self.struct_full_tuple.pack(*entry) + self.shm_list[self.next_slot] = packed + self.used_slots.add(self.next_slot) + proto = IPProtocol(proto) + debug3( + f"ConnTrack: added ({proto.name} {src_addr}:{src_port}->{dst_addr}:{dst_port} @{state_epoch}:{state.name}) to " + f"slot={self.next_slot} | #ActiveConn={len(self.used_slots)}" + ) + + @synchronized_method("rlock") + def update(self, proto, src_addr, src_port, state): + if not self.is_owner: + raise RuntimeError("Only owner can mutate ConnTrack") + src_addr = ip_address(src_addr) + packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) + for i in self.used_slots: + if self.shm_list[i].startswith(packed): + state_epoch = int(time.time()) + self.shm_list[i] = self.shm_list[i][:-5] + self.struct_state_tuple.pack(state_epoch, state) + debug3( + f"ConnTrack: updated ({proto.name} {src_addr}:{src_port} @{state_epoch}:{state.name}) from slot={i} | " + f"#ActiveConn={len(self.used_slots)}" + ) + return self._unpack(self.shm_list[i]) + else: + debug3( + f"ConnTrack: ({proto.name} src={src_addr}:{src_port}) is not found to update to {state.name} | " + f"#ActiveConn={len(self.used_slots)}" + ) + + @synchronized_method("rlock") + def remove(self, proto, src_addr, src_port): + if not self.is_owner: + raise RuntimeError("Only owner can mutate ConnTrack") + src_addr = ip_address(src_addr) + packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) + for i in self.used_slots: + if self.shm_list[i].startswith(packed): + conn = self._unpack(self.shm_list[i]) + self.shm_list[i] = b"" + self.used_slots.remove(i) + debug3( + f"ConnTrack: removed ({proto.name} src={src_addr}:{src_port} state={conn.state.name}) from slot={i} | " + f"#ActiveConn={len(self.used_slots)}" + ) + return conn + else: + debug3( + f"ConnTrack: ({proto.name} src={src_addr}:{src_port}) is not found to remove |" + f" #ActiveConn={len(self.used_slots)}" + ) + + def get(self, proto, src_addr, src_port): + src_addr = ip_address(src_addr) + packed = self.struct_src_tuple.pack(proto, src_addr.version, src_addr.packed, src_port) + for entry in self.shm_list: + if entry and entry.startswith(packed): + return self._unpack(entry) + + def dump(self): + for entry in self.shm_list: + if not entry: + continue + conn = self._unpack(entry) + proto, ip_version, src_addr, src_port, dst_addr, dst_port, state_epoch, state = conn + log(f"{proto.name}/{ip_version} {src_addr}:{src_port} -> {dst_addr}:{dst_port} {state.name}@{state_epoch}") + + @synchronized_method("rlock") + def gc(self, connection_timeout_sec=15): + # self.dump() + now = int(time.time()) + n = 0 + for i in tuple(self.used_slots): + state_packed = self.shm_list[i][-5:] + (state_epoch, state) = self.struct_state_tuple.unpack(state_packed) + if (now - state_epoch) < connection_timeout_sec: + continue + if ConnState.can_timeout(state): + conn = self._unpack(self.shm_list[i]) + self.shm_list[i] = b"" + self.used_slots.remove(i) + n += 1 + debug3( + f"ConnTrack: GC: removed ({conn.protocol.name} src={conn.src_addr}:{conn.src_port} state={conn.state.name})" + f" from slot={i} | #ActiveConn={len(self.used_slots)}" + ) + debug3(f"ConnTrack: GC: collected {n} connections | #ActiveConn={len(self.used_slots)}") + + def _unpack(self, packed): + ( + proto, + ip_version, + src_addr_packed, + src_port, + dst_addr_packed, + dst_port, + state_epoch, + state, + ) = self.struct_full_tuple.unpack(packed) + dst_addr = ip_address(dst_addr_packed if ip_version == 6 else dst_addr_packed[:4]).exploded + src_addr = ip_address(src_addr_packed if ip_version == 6 else src_addr_packed[:4]).exploded + proto = IPProtocol(proto) + state = ConnState(state) + return ConnectionTuple(proto, ip_version, src_addr, src_port, dst_addr, dst_port, state_epoch, state) + + def __iter__(self): + def conn_iter(): + for i in self.used_slots: + yield self._unpack(self.shm_list[i]) + + return conn_iter() + + def __repr__(self): + return f"" + + +class Method(BaseMethod): + + network_config = {} + + def __init__(self, name): + super().__init__(name) + + def _get_bind_address_for_port(self, port, family): + proto = "TCPv6" if family.version == 6 else "TCP" + for line in subprocess.check_output(["netstat", "-a", "-n", "-p", proto]).decode(errors='ignore').splitlines(): + try: + _, local_addr, _, state, *_ = re.split(r"\s+", line.strip()) + except ValueError: + continue + port_suffix = ":" + str(port) + if state == "LISTENING" and local_addr.endswith(port_suffix): + return ip_address(local_addr[:-len(port_suffix)].strip("[]")) + raise Fatal("Could not find listening address for {}/{}".format(port, proto)) + + def setup_firewall(self, proxy_port, dnsport, nslist, family, subnets, udp, user, group, tmark): + debug2(f"{proxy_port=}, {dnsport=}, {nslist=}, {family=}, {subnets=}, {udp=}, {user=}, {group=} {tmark=}") + + if nslist or user or udp or group: + raise NotImplementedError("user, group, nslist, udp are not supported") + + family = IPFamily(family) + + proxy_ip = None + # using loopback only proxy binding won't work with windivert. + # See: https://github.com/basil00/Divert/issues/17#issuecomment-341100167 https://github.com/basil00/Divert/issues/82) + # As a workaround, finding another interface ip instead. (client should not bind proxy to loopback address) + proxy_bind_addr = self._get_bind_address_for_port(proxy_port, family) + if proxy_bind_addr.is_loopback: + raise Fatal("Windivert method requires proxy to be reachable by a non loopback address.") + if not proxy_bind_addr.is_unspecified: + proxy_ip = proxy_bind_addr + else: + local_addresses = [ip_address(info[4][0]) for info in socket.getaddrinfo(socket.gethostname(), 0, family=family)] + for addr in local_addresses: + if not addr.is_loopback and not addr.is_link_local: + proxy_ip = addr + break + else: + raise Fatal("Windivert method requires proxy to be reachable by a non loopback address." + f"No address found for {family.name} in {local_addresses}") + debug2(f"Found non loopback address to connect to proxy: {proxy_ip}") + subnet_addresses = [] + for (_, mask, exclude, network_addr, fport, lport) in subnets: + if fport and lport: + if lport > fport: + raise Fatal("lport must be less than or equal to fport") + ports = (fport, lport) + else: + ports = None + subnet_addresses.append((ip_network(f"{network_addr}/{mask}"), ports, exclude)) + + self.network_config[family] = { + "subnets": subnet_addresses, + "nslist": nslist, + "proxy_addr": (proxy_ip, proxy_port) + } + + def wait_for_firewall_ready(self, sshuttle_pid): + debug2(f"network_config={self.network_config}") + self.conntrack = ConnTrack(f"sshuttle-windivert-{sshuttle_pid}", WINDIVERT_MAX_CONNECTIONS) + if not self.conntrack.is_owner: + raise Fatal("ConnTrack should be owner in wait_for_firewall_ready()") + thread_target_funcs = (self._egress_divert, self._ingress_divert, self._connection_gc) + ready_events = [] + for fn in thread_target_funcs: + ev = threading.Event() + ready_events.append(ev) + + def _target(): + try: + fn(ev.set) + except Exception: + debug2(f"thread {fn.__name__} exiting due to: " + traceback.format_exc()) + sys.stdin.close() # this will exist main thread + sys.stdout.close() + + threading.Thread(name=fn.__name__, target=_target, daemon=True).start() + for ev in ready_events: + if not ev.wait(5): # at most 5 sec + raise Fatal("timeout in wait_for_firewall_ready()") + + def restore_firewall(self, port, family, udp, user, group): + pass + + def get_supported_features(self): + result = super(Method, self).get_supported_features() + result.loopback_proxy_port = False + result.user = False + result.dns = False + # ipv6 only able to support with Windivert 2.x due to bugs in filter parsing + # TODO(nom3ad): Enable ipv6 once https://github.com/ffalcinelli/pydivert/pull/57 merged + result.ipv6 = False + return result + + def get_tcp_dstip(self, sock): + if not hasattr(self, "conntrack"): + self.conntrack = ConnTrack(f"sshuttle-windivert-{os.getpid()}") + if self.conntrack.is_owner: + raise Fatal("ConnTrack should not be owner in get_tcp_dstip()") + + src_addr, src_port = sock.getpeername() + c = self.conntrack.get(IPProtocol.TCP, src_addr, src_port) + if not c: + return (src_addr, src_port) + return (c.dst_addr, c.dst_port) + + def is_supported(self): + if sys.platform == "win32": + return True + return False + + def _egress_divert(self, ready_cb): + """divert outgoing packets to proxy""" + proto = IPProtocol.TCP + filter = f"outbound and {proto.filter}" + af_filters = [] + for af, c in self.network_config.items(): + subnet_include_filters = [] + subnet_exclude_filters = [] + for ip_net, ports, exclude in c["subnets"]: + first_ip = ip_net.network_address.exploded + last_ip = ip_net.broadcast_address.exploded + if first_ip == last_ip: + _subnet_filter = f"{af.filter}.DstAddr=={first_ip}" + else: + _subnet_filter = f"{af.filter}.DstAddr>={first_ip} and {af.filter}.DstAddr<={last_ip}" + if ports: + if ports[0] == ports[1]: + _subnet_filter += f" and {proto.filter}.DstPort=={ports[0]}" + else: + _subnet_filter += f" and tcp.DstPort>={ports[0]} and tcp.DstPort<={ports[1]}" + (subnet_exclude_filters if exclude else subnet_include_filters).append(f"({_subnet_filter})") + _af_filter = f"{af.filter}" + if subnet_include_filters: + _af_filter += f" and ({' or '.join(subnet_include_filters)})" + if subnet_exclude_filters: + # TODO(noma3ad) use not() operator with Windivert2 after upgrade + _af_filter += f" and (({' or '.join(subnet_exclude_filters)})? false : true)" + proxy_ip, proxy_port = c["proxy_addr"] + # Avoids proxy outbound traffic getting directed to itself + proxy_guard_filter = f"(({af.filter}.DstAddr=={proxy_ip.exploded} and tcp.DstPort=={proxy_port})? false : true)" + _af_filter += f" and {proxy_guard_filter}" + af_filters.append(_af_filter) + if not af_filters: + raise Fatal("At least one ipv4 or ipv6 subnet is expected") + + filter = f"{filter} and ({' or '.join(af_filters)})" + debug1(f"[EGRESS] {filter=}") + with pydivert.WinDivert(filter, layer=pydivert.Layer.NETWORK, flags=pydivert.Flag.DEFAULT) as w: + proxy_ipv4, proxy_ipv6 = None, None + if IPFamily.IPv4 in self.network_config: + proxy_ipv4 = self.network_config[IPFamily.IPv4]["proxy_addr"] + proxy_ipv4 = proxy_ipv4[0].exploded, proxy_ipv4[1] + if IPFamily.IPv6 in self.network_config: + proxy_ipv6 = self.network_config[IPFamily.IPv6]["proxy_addr"] + proxy_ipv6 = proxy_ipv6[0].exploded, proxy_ipv6[1] + ready_cb() + verbose = get_verbose_level() + for pkt in w: + verbose >= 3 and debug3("[EGRESS] " + repr_pkt(pkt)) + if pkt.tcp.syn and not pkt.tcp.ack: + # SYN sent (start of 3-way handshake connection establishment from our side, we wait for SYN+ACK) + self.conntrack.add( + socket.IPPROTO_TCP, + pkt.src_addr, + pkt.src_port, + pkt.dst_addr, + pkt.dst_port, + ConnState.TCP_SYN_SENT, + ) + if pkt.tcp.fin: + # FIN sent (start of graceful close our side, and we wait for ACK) + self.conntrack.update(IPProtocol.TCP, pkt.src_addr, pkt.src_port, ConnState.TCP_FIN_WAIT_1) + if pkt.tcp.rst: + # RST sent (initiate abrupt connection teardown from our side, so we don't expect any reply) + self.conntrack.remove(IPProtocol.TCP, pkt.src_addr, pkt.src_port) + + # DNAT + if pkt.ipv4 and proxy_ipv4: + pkt.dst_addr, pkt.tcp.dst_port = proxy_ipv4 + if pkt.ipv6 and proxy_ipv6: + pkt.dst_addr, pkt.tcp.dst_port = proxy_ipv6 + + # XXX: If we set loopback proxy address (DNAT), then we should do SNAT as well + # by setting src_addr to loopback address. + # Otherwise injecting packet will be ignored by Windows network stack + # as they packet has to cross public to private address space. + # See: https://github.com/basil00/Divert/issues/82 + # Managing SNAT is more trickier, as we have to restore the original source IP address for reply packets. + # >>> pkt.dst_addr = proxy_ipv4 + w.send(pkt, recalculate_checksum=True) + + def _ingress_divert(self, ready_cb): + """handles incoming packets from proxy""" + proto = IPProtocol.TCP + # Windivert treats all local process traffic as outbound, regardless of origin external/loopback iface + direction = "outbound" + proxy_addr_filters = [] + for af, c in self.network_config.items(): + if not c["subnets"]: + continue + proxy_ip, proxy_port = c["proxy_addr"] + # "ip.SrcAddr=={hex(int(proxy_ip))}" # only Windivert >=2 supports this + proxy_addr_filters.append(f"{af.filter}.SrcAddr=={proxy_ip.exploded} and tcp.SrcPort=={proxy_port}") + if not proxy_addr_filters: + raise Fatal("At least one ipv4 or ipv6 address is expected") + filter = f"{direction} and {proto.filter} and ({' or '.join(proxy_addr_filters)})" + debug1(f"[INGRESS] {filter=}") + with pydivert.WinDivert(filter, layer=pydivert.Layer.NETWORK, flags=pydivert.Flag.DEFAULT) as w: + ready_cb() + verbose = get_verbose_level() + for pkt in w: + verbose >= 3 and debug3("[INGRESS] " + repr_pkt(pkt)) + if pkt.tcp.syn and pkt.tcp.ack: + # SYN+ACK received (connection established from proxy + conn = self.conntrack.update(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port, ConnState.TCP_ESTABLISHED) + elif pkt.tcp.rst: + # RST received - Abrupt connection teardown initiated by proxy. Don't expect anymore packets + conn = self.conntrack.remove(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port) + # https://wiki.wireshark.org/TCP-4-times-close.md + elif pkt.tcp.fin and pkt.tcp.ack: + # FIN+ACK received (Passive close by proxy. Don't expect any more packets. proxy expects an ACK) + conn = self.conntrack.remove(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port) + elif pkt.tcp.fin: + # FIN received (proxy initiated graceful close. Expect a final ACK for a FIN packet) + conn = self.conntrack.update(IPProtocol.TCP, pkt.dst_addr, pkt.dst_port, ConnState.TCP_CLOSE_WAIT) + else: + # data fragments and ACKs + conn = self.conntrack.get(socket.IPPROTO_TCP, pkt.dst_addr, pkt.dst_port) + if not conn: + verbose >= 2 and debug2("Unexpected packet: " + repr_pkt(pkt)) + continue + pkt.src_addr = conn.dst_addr + pkt.tcp.src_port = conn.dst_port + w.send(pkt, recalculate_checksum=True) + + def _connection_gc(self, ready_cb): + ready_cb() + while True: + time.sleep(5) + self.conntrack.gc() diff --git a/sshuttle/namespace.py b/sshuttle/namespace.py new file mode 100644 index 000000000..f168b747b --- /dev/null +++ b/sshuttle/namespace.py @@ -0,0 +1,40 @@ +import os +import ctypes +import ctypes.util + +from sshuttle.helpers import Fatal, debug1, debug2 + + +CLONE_NEWNET = 0x40000000 +NETNS_RUN_DIR = "/var/run/netns" + + +def enter_namespace(namespace, namespace_pid): + if namespace: + namespace_dir = f'{NETNS_RUN_DIR}/{namespace}' + else: + namespace_dir = f'/proc/{namespace_pid}/ns/net' + + if not os.path.exists(namespace_dir): + raise Fatal('The namespace %r does not exists.' % namespace_dir) + + debug2('loading libc') + libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) + + default_errcheck = libc.setns.errcheck + + def errcheck(ret, *args): + if ret == -1: + e = ctypes.get_errno() + raise Fatal(e, os.strerror(e)) + if default_errcheck: + return default_errcheck(ret, *args) + + libc.setns.errcheck = errcheck # type: ignore + + debug1('Entering namespace %r' % namespace_dir) + + with open(namespace_dir) as fd: + libc.setns(fd.fileno(), CLONE_NEWNET) + + debug1('Namespace %r successfully set' % namespace_dir) diff --git a/sshuttle/options.py b/sshuttle/options.py index a0a06c30d..433abdf64 100644 --- a/sshuttle/options.py +++ b/sshuttle/options.py @@ -1,5 +1,6 @@ import re import socket +import sys from argparse import ArgumentParser, Action, ArgumentTypeError as Fatal from sshuttle import __version__ @@ -37,9 +38,9 @@ def parse_subnetport_file(s): def parse_subnetport(s): if s.count(':') > 1: - rx = r'(?:\[?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$' + rx = r'(?:\[?(?:\*\.)?([\w\:]+)(?:/(\d+))?]?)(?::(\d+)(?:-(\d+))?)?$' else: - rx = r'([\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$' + rx = r'((?:\*\.)?[\w\.\-]+)(?:/(\d+))?(?::(\d+)(?:-(\d+))?)?$' m = re.match(rx, s) if not m: @@ -136,6 +137,15 @@ def parse_list(lst): return re.split(r'[\s,]+', lst.strip()) if lst else [] +def parse_namespace(namespace): + try: + assert re.fullmatch( + r'(@?[a-z_A-Z]\w+(?:\.@?[a-z_A-Z]\w+)*)', namespace) + return namespace + except AssertionError: + raise Fatal("%r is not a valid namespace name." % namespace) + + class Concat(Action): def __init__(self, option_strings, dest, nargs=None, **kwargs): if nargs is not None: @@ -234,9 +244,14 @@ def convert_arg_line_to_args(self, arg_line): """ ) +if sys.platform == 'win32': + method_choices = ["auto", "windivert"] +else: + method_choices = ["auto", "nft", "nat", "tproxy", "pf", "ipfw"] + parser.add_argument( "--method", - choices=["auto", "nat", "nft", "tproxy", "pf", "ipfw"], + choices=method_choices, metavar="TYPE", default="auto", help=""" @@ -301,6 +316,22 @@ def convert_arg_line_to_args(self, arg_line): the command to use to connect to the remote [%(default)s] """ ) +parser.add_argument( + "--no-cmd-delimiter", + action="http://github.com/store_false", + dest="add_cmd_delimiter", + help=""" + do not add a double dash before the python command + """ +) +parser.add_argument( + "--remote-shell", + metavar="PROGRAM", + help=""" + alternate remote shell program instead of defacto posix shell. + For Windows targets it would be either `cmd` or `powershell` unless something like git-bash is in use. + """ +) parser.add_argument( "--seed-hosts", metavar="HOSTNAME[,HOSTNAME]", @@ -383,31 +414,34 @@ def convert_arg_line_to_args(self, arg_line): """ ) parser.add_argument( - "--firewall", - action="http://github.com/store_true", + "--group", help=""" - (internal use only) + apply all the rules only to this linux group """ ) parser.add_argument( - "--hostwatch", + "--firewall", action="http://github.com/store_true", help=""" (internal use only) """ ) parser.add_argument( - "--sudoers", + "--hostwatch", action="http://github.com/store_true", help=""" - Add sshuttle to the sudoers for this user + (internal use only) """ ) parser.add_argument( "--sudoers-no-modify", action="http://github.com/store_true", help=""" - Prints the sudoers config to STDOUT and DOES NOT modify anything. + Prints a sudo configuration to STDOUT which allows a user to + run sshuttle without a password. This option is INSECURE because, + with some cleverness, it also allows the user to run any command + as root without a password. The output also includes a suggested + method for you to install the configuration. """ ) parser.add_argument( @@ -415,16 +449,7 @@ def convert_arg_line_to_args(self, arg_line): default="", help=""" Set the user name or group with %%group_name for passwordless operation. - Default is the current user.set ALL for all users. Only works with - --sudoers or --sudoers-no-modify option. - """ -) -parser.add_argument( - "--sudoers-filename", - default="sshuttle_auto", - help=""" - Set the file name for the sudoers.d file to be added. Default is - "sshuttle_auto". Only works with --sudoers or --sudoers-no-modify option. + Default is the current user. Only works with the --sudoers-no-modify option. """ ) parser.add_argument( @@ -444,3 +469,20 @@ def convert_arg_line_to_args(self, arg_line): hexadecimal (default '0x01') """ ) + +if sys.platform == 'linux': + net_ns_group = parser.add_mutually_exclusive_group( + required=False) + + net_ns_group.add_argument( + '--namespace', + type=parse_namespace, + help="Run inside of a net namespace with the given name." + ) + net_ns_group.add_argument( + '--namespace-pid', + type=int, + help=""" + Run inside the net namespace used by the process with + the given pid.""" + ) diff --git a/sshuttle/server.py b/sshuttle/server.py index a9c14228e..4350dfb7d 100644 --- a/sshuttle/server.py +++ b/sshuttle/server.py @@ -5,6 +5,7 @@ import time import sys import os +import io import sshuttle.ssnet as ssnet @@ -13,7 +14,7 @@ import subprocess as ssubprocess from sshuttle.ssnet import Handler, Proxy, Mux, MuxWrapper from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, \ - resolvconf_random_nameserver, which, get_env + get_random_nameserver, which, get_env, SocketRWShim def _ipmatch(ipstr): @@ -34,7 +35,6 @@ def _ipmatch(ipstr): elif g[3] is None: ips += '.0' width = min(width, 24) - ips = ips return (struct.unpack('!I', socket.inet_aton(ips))[0], width) @@ -79,6 +79,20 @@ def _route_iproute(line): return ipw, int(mask) +def _route_windows(line): + if " On-link " not in line: + return None, None + dest, net_mask = re.split(r'\s+', line.strip())[:2] + if net_mask == "255.255.255.255": + return None, None + for p in ('127.', '0.', '224.', '169.254.'): + if dest.startswith(p): + return None, None + ipw = _ipmatch(dest) + mask = _maskbits(_ipmatch(net_mask)) + return ipw, mask + + def _list_routes(argv, extract_route): # FIXME: IPv4 only p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) @@ -101,14 +115,17 @@ def _list_routes(argv, extract_route): def list_routes(): - if which('ip'): - routes = _list_routes(['ip', 'route'], _route_iproute) - elif which('netstat'): - routes = _list_routes(['netstat', '-rn'], _route_netstat) + if sys.platform == 'win32': + routes = _list_routes(['route', 'PRINT', '-4'], _route_windows) else: - log('WARNING: Neither "ip" nor "netstat" were found on the server. ' - '--auto-nets feature will not work.') - routes = [] + if which('ip'): + routes = _list_routes(['ip', 'route'], _route_iproute) + elif which('netstat'): + routes = _list_routes(['netstat', '-rn'], _route_netstat) + else: + log('WARNING: Neither "ip" nor "netstat" were found on the server. ' + '--auto-nets feature will not work.') + routes = [] for (family, ip, width) in routes: if not ip.startswith('0.') and not ip.startswith('127.'): @@ -182,7 +199,7 @@ def try_send(self): self.tries += 1 if self.to_nameserver is None: - _, peer = resolvconf_random_nameserver(False) + _, peer = get_random_nameserver() port = 53 else: peer = self.to_ns_peer @@ -282,7 +299,16 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver, sys.stdout.flush() handlers = [] - mux = Mux(sys.stdin, sys.stdout) + # get unbuffered stdin and stdout in binary mode. Equivalent to stdin.buffer/stdout.buffer (Only available in Python 3) + r, w = io.FileIO(0, mode='r'), io.FileIO(1, mode='w') + if sys.platform == 'win32': + def _deferred_exit(): + time.sleep(1) # give enough time to write logs to stderr + os._exit(23) + shim = SocketRWShim(r, w, on_end=_deferred_exit) + mux = Mux(*shim.makefiles()) + else: + mux = Mux(r, w) handlers.append(mux) debug1('auto-nets:' + str(auto_nets)) @@ -303,7 +329,7 @@ def main(latency_control, latency_buffer_size, auto_hosts, to_nameserver, hw.leftover = b('') def hostwatch_ready(sock): - assert(hw.pid) + assert hw.pid content = hw.sock.recv(4096) if content: lines = (hw.leftover + content).split(b('\n')) @@ -381,7 +407,7 @@ def udp_open(channel, data): while mux.ok: if hw.pid: - assert(hw.pid > 0) + assert hw.pid > 0 (rpid, rv) = os.waitpid(hw.pid, os.WNOHANG) if rpid: raise Fatal( diff --git a/sshuttle/ssh.py b/sshuttle/ssh.py index 7ced918f1..c4e417ecc 100644 --- a/sshuttle/ssh.py +++ b/sshuttle/ssh.py @@ -12,7 +12,7 @@ from urllib.parse import urlparse import sshuttle.helpers as helpers -from sshuttle.helpers import debug2, which, get_path, Fatal +from sshuttle.helpers import debug2, which, get_path, SocketRWShim, Fatal def get_module_source(name): @@ -56,7 +56,7 @@ def parse_hostport(rhostport): # Fix #410 bad username error detect if ":" in username: # this will even allow for the username to be empty - username, password = username.split(":") + username, password = username.split(":", 1) if ":" in host: # IPv6 address and/or got a port specified @@ -84,7 +84,7 @@ def parse_hostport(rhostport): return username, password, port, host -def connect(ssh_cmd, rhostport, python, stderr, options): +def connect(ssh_cmd, rhostport, python, stderr, add_cmd_delimiter, remote_shell, options): username, password, port, host = parse_hostport(rhostport) if username: rhost = "{}@{}".format(username, host) @@ -115,8 +115,8 @@ def connect(ssh_cmd, rhostport, python, stderr, options): pyscript = r""" import sys, os; verbosity=%d; - sys.stdin = os.fdopen(0, "rb"); - exec(compile(sys.stdin.read(%d), "assembler.py", "exec")); + stdin = os.fdopen(0, 'rb'); + exec(compile(stdin.read(%d), 'assembler.py', 'exec')); sys.exit(98); """ % (helpers.verbose or 0, len(content)) pyscript = re.sub(r'\s+', ' ', pyscript.strip()) @@ -134,62 +134,72 @@ def connect(ssh_cmd, rhostport, python, stderr, options): portl = ["-p", str(port)] else: portl = [] - if python: - pycmd = "'%s' -c '%s'" % (python, pyscript) - else: - # By default, we run the following code in a shell. - # However, with restricted shells and other unusual - # situations, there can be trouble. See the RESTRICTED - # SHELL section in "man bash" for more information. The - # code makes many assumptions: - # - # (1) That /bin/sh exists and that we can call it. - # Restricted shells often do *not* allow you to run - # programs specified with an absolute path like /bin/sh. - # Either way, if there is trouble with this, it should - # return error code 127. - # - # (2) python3 or python exists in the PATH and is - # executable. If they aren't, then exec won't work (see (4) - # below). - # - # (3) In /bin/sh, that we can redirect stderr in order to - # hide the version that "python3 -V" might print (some - # restricted shells don't allow redirection, see - # RESTRICTED SHELL section in 'man bash'). However, if we - # are in a restricted shell, we'd likely have trouble with - # assumption (1) above. - # - # (4) The 'exec' command should work except if we failed - # to exec python because it doesn't exist or isn't - # executable OR if exec isn't allowed (some restricted - # shells don't allow exec). If the exec succeeded, it will - # not return and not get to the "exit 97" command. If exec - # does return, we exit with code 97. - # - # Specifying the exact python program to run with --python - # avoids many of the issues above. However, if - # you have a restricted shell on remote, you may only be - # able to run python if it is in your PATH (and you can't - # run programs specified with an absolute path). In that - # case, sshuttle might not work at all since it is not - # possible to run python on the remote machine---even if - # it is present. - pycmd = ("P=python3; $P -V 2>%s || P=python; " - "exec \"$P\" -c %s; exit 97") % \ - (os.devnull, quote(pyscript)) - pycmd = ("/bin/sh -c {}".format(quote(pycmd))) + if remote_shell == "cmd": + pycmd = '"%s" -c "%s"' % (python or 'python', pyscript) + elif remote_shell == "powershell": + for c in ('\'', ' ', ';', '(', ')', ','): + pyscript = pyscript.replace(c, '`' + c) + pycmd = '%s -c %s' % (python or 'python', pyscript) + else: # posix shell expected + if python: + pycmd = '"%s" -c "%s"' % (python, pyscript) + else: + # By default, we run the following code in a shell. + # However, with restricted shells and other unusual + # situations, there can be trouble. See the RESTRICTED + # SHELL section in "man bash" for more information. The + # code makes many assumptions: + # + # (1) That /bin/sh exists and that we can call it. + # Restricted shells often do *not* allow you to run + # programs specified with an absolute path like /bin/sh. + # Either way, if there is trouble with this, it should + # return error code 127. + # + # (2) python3 or python exists in the PATH and is + # executable. If they aren't, then exec won't work (see (4) + # below). + # + # (3) In /bin/sh, that we can redirect stderr in order to + # hide the version that "python3 -V" might print (some + # restricted shells don't allow redirection, see + # RESTRICTED SHELL section in 'man bash'). However, if we + # are in a restricted shell, we'd likely have trouble with + # assumption (1) above. + # + # (4) The 'exec' command should work except if we failed + # to exec python because it doesn't exist or isn't + # executable OR if exec isn't allowed (some restricted + # shells don't allow exec). If the exec succeeded, it will + # not return and not get to the "exit 97" command. If exec + # does return, we exit with code 97. + # + # Specifying the exact python program to run with --python + # avoids many of the issues above. However, if + # you have a restricted shell on remote, you may only be + # able to run python if it is in your PATH (and you can't + # run programs specified with an absolute path). In that + # case, sshuttle might not work at all since it is not + # possible to run python on the remote machine---even if + # it is present. + devnull = '/dev/null' + pycmd = ("P=python3; $P -V 2>%s || P=python; " + "exec \"$P\" -c %s; exit 97") % \ + (devnull, quote(pyscript)) + pycmd = ("/bin/sh -c {}".format(quote(pycmd))) if password is not None: os.environ['SSHPASS'] = str(password) argv = (["sshpass", "-e"] + sshl + - portl + - [rhost, '--', pycmd]) + portl + [rhost]) + + else: + argv = (sshl + portl + [rhost]) + if add_cmd_delimiter: + argv += ['--', pycmd] else: - argv = (sshl + - portl + - [rhost, '--', pycmd]) + argv += [pycmd] # Our which() function searches for programs in get_path() # directories (which include PATH). This step isn't strictly @@ -201,19 +211,45 @@ def connect(ssh_cmd, rhostport, python, stderr, options): raise Fatal("Failed to find '%s' in path %s" % (argv[0], get_path())) argv[0] = abs_path - (s1, s2) = socket.socketpair() - - def setup(): - # runs in the child process - s2.close() - s1a, s1b = os.dup(s1.fileno()), os.dup(s1.fileno()) - s1.close() - - debug2('executing: %r' % argv) - p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup, - close_fds=True, stderr=stderr) - os.close(s1a) - os.close(s1b) - s2.sendall(content) - s2.sendall(content2) - return p, s2 + if sys.platform != 'win32': + (s1, s2) = socket.socketpair() + pstdin, pstdout = os.dup(s1.fileno()), os.dup(s1.fileno()) + + def preexec_fn(): + # runs in the child process + s2.close() + s1.close() + + def get_server_io(): + os.close(pstdin) + os.close(pstdout) + return s2.makefile("rb", buffering=0), s2.makefile("wb", buffering=0) + else: + # In Windows CPython, BSD sockets are not supported as subprocess stdio + # and select.select() used in ssnet.py won't work on Windows pipes. + # So we have to use both socketpair (for select.select) and pipes (for subprocess.Popen) together + # along with reader/writer threads to stream data between them + # NOTE: Their could be a better way. Need to investigate further on this. + # Either to use sockets as stdio for subprocess. Or to use pipes but with a select() alternative + # https://stackoverflow.com/questions/4993119/redirect-io-of-process-to-windows-socket + + pstdin = ssubprocess.PIPE + pstdout = ssubprocess.PIPE + + preexec_fn = None + + def get_server_io(): + shim = SocketRWShim(p.stdout, p.stdin, on_end=lambda: p.terminate()) + return shim.makefiles() + + # See: stackoverflow.com/questions/48671215/howto-workaround-of-close-fds-true-and-redirect-stdout-stderr-on-windows + close_fds = False if sys.platform == 'win32' else True + + debug2("executing: %r" % argv) + p = ssubprocess.Popen(argv, stdin=pstdin, stdout=pstdout, preexec_fn=preexec_fn, + close_fds=close_fds, stderr=stderr, bufsize=0) + + rfile, wfile = get_server_io() + wfile.write(content) + wfile.write(content2) + return p, rfile, wfile diff --git a/sshuttle/ssnet.py b/sshuttle/ssnet.py index eebe22784..6c32a92d1 100644 --- a/sshuttle/ssnet.py +++ b/sshuttle/ssnet.py @@ -4,9 +4,8 @@ import errno import select import os -import fcntl -from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal +from sshuttle.helpers import b, log, debug1, debug2, debug3, Fatal, set_non_blocking_io MAX_CHANNEL = 65535 LATENCY_BUFFER_SIZE = 32768 @@ -78,7 +77,8 @@ def _fds(socks): def _nb_clean(func, *args): try: return func(*args) - except OSError: + except (OSError, socket.error): + # Note: In python2 socket.error != OSError (In python3, they are same) _, e = sys.exc_info()[:2] if e.errno not in (errno.EWOULDBLOCK, errno.EAGAIN): raise @@ -168,19 +168,25 @@ def try_connect(self): debug3('%r: fixed connect result: %s' % (self, e)) if e.args[0] in [errno.EINPROGRESS, errno.EALREADY]: pass # not connected yet + elif sys.platform == 'win32' and e.args[0] == errno.WSAEWOULDBLOCK: # 10035 + pass # not connected yet elif e.args[0] == 0: - # connected successfully (weird Linux bug?) - # Sometimes Linux seems to return EINVAL when it isn't - # invalid. This *may* be caused by a race condition - # between connect() and getsockopt(SO_ERROR) (ie. it - # finishes connecting in between the two, so there is no - # longer an error). However, I'm not sure of that. - # - # I did get at least one report that the problem went away - # when we added this, however. - self.connect_to = None + if sys.platform == 'win32': + # On Windows "real" error of EINVAL could be 0, when socket is in connecting state + pass + else: + # connected successfully (weird Linux bug?) + # Sometimes Linux seems to return EINVAL when it isn't + # invalid. This *may* be caused by a race condition + # between connect() and getsockopt(SO_ERROR) (ie. it + # finishes connecting in between the two, so there is no + # longer an error). However, I'm not sure of that. + # + # I did get at least one report that the problem went away + # when we added this, however. + self.connect_to = None elif e.args[0] == errno.EISCONN: - # connected successfully (BSD) + # connected successfully (BSD + Windows) self.connect_to = None elif e.args[0] in NET_ERRS + [errno.EACCES, errno.EPERM]: # a "normal" kind of error @@ -193,7 +199,6 @@ def noread(self): if not self.shut_read: debug2('%r: done reading' % self) self.shut_read = True - # self.rsock.shutdown(SHUT_RD) # doesn't do anything anyway def nowrite(self): if not self.shut_write: @@ -214,7 +219,7 @@ def uwrite(self, buf): return 0 # still connecting self.wsock.setblocking(False) try: - return _nb_clean(os.write, self.wsock.fileno(), buf) + return _nb_clean(self.wsock.send, buf) except OSError: _, e = sys.exc_info()[:2] if e.errno == errno.EPIPE: @@ -227,7 +232,7 @@ def uwrite(self, buf): return 0 def write(self, buf): - assert(buf) + assert buf return self.uwrite(buf) def uread(self): @@ -237,7 +242,7 @@ def uread(self): return self.rsock.setblocking(False) try: - return _nb_clean(os.read, self.rsock.fileno(), 65536) + return _nb_clean(self.rsock.recv, 65536) except OSError: _, e = sys.exc_info()[:2] self.seterr('uread: %s' % e) @@ -373,11 +378,6 @@ def check_fullness(self): if not self.too_full: self.send(0, CMD_PING, b('rttest')) self.too_full = True - # ob = [] - # for b in self.outbuf: - # (s1,s2,c) = struct.unpack('!ccH', b[:4]) - # ob.append(c) - # log('outbuf: %d %r' % (self.amount_queued(), ob)) def send(self, channel, cmd, data): assert isinstance(data, bytes) @@ -388,11 +388,13 @@ def send(self, channel, cmd, data): debug2(' > channel=%d cmd=%s len=%d (fullness=%d)' % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data), self.fullness)) + # debug3('>>> data: %r' % data) self.fullness += len(data) def got_packet(self, channel, cmd, data): debug2('< channel=%d cmd=%s len=%d' % (channel, cmd_to_name.get(cmd, hex(cmd)), len(data))) + # debug3('<<< data: %r' % data) if cmd == CMD_PING: self.send(0, CMD_PONG, data) elif cmd == CMD_PONG: @@ -402,15 +404,15 @@ def got_packet(self, channel, cmd, data): elif cmd == CMD_EXIT: self.ok = False elif cmd == CMD_TCP_CONNECT: - assert(not self.channels.get(channel)) + assert not self.channels.get(channel) if self.new_channel: self.new_channel(channel, data) elif cmd == CMD_DNS_REQ: - assert(not self.channels.get(channel)) + assert not self.channels.get(channel) if self.got_dns_req: self.got_dns_req(channel, data) elif cmd == CMD_UDP_OPEN: - assert(not self.channels.get(channel)) + assert not self.channels.get(channel) if self.got_udp_open: self.got_udp_open(channel, data) elif cmd == CMD_ROUTES: @@ -437,15 +439,10 @@ def got_packet(self, channel, cmd, data): callback(cmd, data) def flush(self): - try: - os.set_blocking(self.wfile.fileno(), False) - except AttributeError: - # python < 3.5 - flags = fcntl.fcntl(self.wfile.fileno(), fcntl.F_GETFL) - flags |= os.O_NONBLOCK - flags = fcntl.fcntl(self.wfile.fileno(), fcntl.F_SETFL, flags) + set_non_blocking_io(self.wfile.fileno()) if self.outbuf and self.outbuf[0]: - wrote = _nb_clean(os.write, self.wfile.fileno(), self.outbuf[0]) + wrote = _nb_clean(self.wfile.write, self.outbuf[0]) + # self.wfile.flush() debug2('mux wrote: %r/%d' % (wrote, len(self.outbuf[0]))) if wrote: self.outbuf[0] = self.outbuf[0][wrote:] @@ -453,18 +450,12 @@ def flush(self): self.outbuf[0:1] = [] def fill(self): - try: - os.set_blocking(self.rfile.fileno(), False) - except AttributeError: - # python < 3.5 - flags = fcntl.fcntl(self.rfile.fileno(), fcntl.F_GETFL) - flags |= os.O_NONBLOCK - flags = fcntl.fcntl(self.rfile.fileno(), fcntl.F_SETFL, flags) + set_non_blocking_io(self.rfile.fileno()) try: # If LATENCY_BUFFER_SIZE is inappropriately large, we will # get a MemoryError here. Read no more than 1MiB. - read = _nb_clean(os.read, self.rfile.fileno(), - min(1048576, LATENCY_BUFFER_SIZE)) + read = _nb_clean(self.rfile.read, min(1048576, LATENCY_BUFFER_SIZE)) + debug2('mux read: %r' % len(read)) except OSError: _, e = sys.exc_info()[:2] raise Fatal('other end: %r' % e) @@ -476,14 +467,12 @@ def fill(self): def handle(self): self.fill() - # log('inbuf is: (%d,%d) %r' - # % (self.want, len(self.inbuf), self.inbuf)) while 1: if len(self.inbuf) >= (self.want or HDR_LEN): (s1, s2, channel, cmd, datalen) = \ struct.unpack('!ccHHH', self.inbuf[:HDR_LEN]) - assert(s1 == b('S')) - assert(s2 == b('S')) + assert s1 == b('S') + assert s2 == b('S') self.want = datalen + HDR_LEN if self.want and len(self.inbuf) >= self.want: data = self.inbuf[HDR_LEN:self.want] diff --git a/sshuttle/ssyslog.py b/sshuttle/ssyslog.py index 630c00e94..30118723b 100644 --- a/sshuttle/ssyslog.py +++ b/sshuttle/ssyslog.py @@ -10,7 +10,7 @@ def start_syslog(): global _p with open(os.devnull, 'w') as devnull: _p = ssubprocess.Popen( - ['logger', '-p', 'daemon.notice', '-t', 'sshuttle'], + ['logger', '-p', 'daemon.err', '-t', 'sshuttle'], stdin=ssubprocess.PIPE, stdout=devnull, stderr=devnull diff --git a/sshuttle/stresstest.py b/sshuttle/stresstest.py deleted file mode 100755 index 490e60af8..000000000 --- a/sshuttle/stresstest.py +++ /dev/null @@ -1,89 +0,0 @@ -#!/usr/bin/env python -import socket -import select -import struct -import time - -listener = socket.socket() -listener.bind(('127.0.0.1', 0)) -listener.listen(500) - -servers = [] -clients = [] -remain = {} - -NUMCLIENTS = 50 -count = 0 - - -while 1: - if len(clients) < NUMCLIENTS: - c = socket.socket() - c.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - c.bind(('0.0.0.0', 0)) - c.connect(listener.getsockname()) - count += 1 - if count >= 16384: - count = 1 - print('cli CREATING %d' % count) - b = struct.pack('I', count) + 'x' * count - remain[c] = count - print('cli >> %r' % len(b)) - c.send(b) - c.shutdown(socket.SHUT_WR) - clients.append(c) - r = [listener] - time.sleep(0.1) - else: - r = [listener] + servers + clients - print('select(%d)' % len(r)) - r, w, x = select.select(r, [], [], 5) - assert(r) - for i in r: - if i == listener: - s, addr = listener.accept() - servers.append(s) - elif i in servers: - b = i.recv(4096) - print('srv << %r' % len(b)) - if i not in remain: - assert(len(b) >= 4) - want = struct.unpack('I', b[:4])[0] - b = b[4:] - # i.send('y'*want) - else: - want = remain[i] - if want < len(b): - print('weird wanted %d bytes, got %d: %r' % (want, len(b), b)) - assert(want >= len(b)) - want -= len(b) - remain[i] = want - if not b: # EOF - if want: - print('weird: eof but wanted %d more' % want) - assert(want == 0) - i.close() - servers.remove(i) - del remain[i] - else: - print('srv >> %r' % len(b)) - i.send('y' * len(b)) - if not want: - i.shutdown(socket.SHUT_WR) - elif i in clients: - b = i.recv(4096) - print('cli << %r' % len(b)) - want = remain[i] - if want < len(b): - print('weird wanted %d bytes, got %d: %r' % (want, len(b), b)) - assert(want >= len(b)) - want -= len(b) - remain[i] = want - if not b: # EOF - if want: - print('weird: eof but wanted %d more' % want) - assert(want == 0) - i.close() - clients.remove(i) - del remain[i] -listener.accept() diff --git a/sshuttle/sudoers.py b/sshuttle/sudoers.py index ea675784e..d1c8b1e44 100644 --- a/sshuttle/sudoers.py +++ b/sshuttle/sudoers.py @@ -2,70 +2,44 @@ import sys import getpass from uuid import uuid4 -from subprocess import Popen, PIPE -from sshuttle.helpers import log, debug1 -from distutils import spawn - -path_to_sshuttle = sys.argv[0] -path_to_dist_packages = os.path.dirname(os.path.abspath(__file__))[:-9] - -# randomize command alias to avoid collisions -command_alias = 'SSHUTTLE%(num)s' % {'num': uuid4().hex[-3:].upper()} - -# Template for the sudoers file -template = ''' -Cmnd_Alias %(ca)s = /usr/bin/env PYTHONPATH=%(dist_packages)s %(py)s %(path)s * - -%(user_name)s ALL=NOPASSWD: %(ca)s -''' - -warning_msg = "# WARNING: When you allow a user to run sshuttle as root,\n" \ - "# they can then use sshuttle's --ssh-cmd option to run any\n" \ - "# command as root.\n" def build_config(user_name): - content = warning_msg - content += template % { - 'ca': command_alias, - 'dist_packages': path_to_dist_packages, - 'py': sys.executable, - 'path': path_to_sshuttle, - 'user_name': user_name, - } + """Generates a sudoers configuration to allow passwordless execution of sshuttle.""" + + argv0 = os.path.abspath(sys.argv[0]) + is_python_script = argv0.endswith('.py') + executable = f"{sys.executable} {argv0}" if is_python_script else argv0 + dist_packages = os.path.dirname(os.path.abspath(__file__)) + cmd_alias = f"SSHUTTLE{uuid4().hex[-3:].upper()}" - return content + template = f""" +# WARNING: If you intend to restrict a user to only running the +# sshuttle command as root, THIS CONFIGURATION IS INSECURE. +# When a user can run sshuttle as root (with or without a password), +# they can also run other commands as root because sshuttle itself +# can run a command specified by the user with the --ssh-cmd option. +# INSTRUCTIONS: Add this text to your sudo configuration to run +# sshuttle without needing to enter a sudo password. To use this +# configuration, run 'visudo /etc/sudoers.d/sshuttle_auto' as root and +# paste this text into the editor that it opens. If you want to give +# multiple users these privileges, you may wish to use different +# filenames for each one (i.e., /etc/sudoers.d/sshuttle_auto_john). -def save_config(content, file_name): - process = Popen([ - '/usr/bin/sudo', - spawn.find_executable('sudoers-add'), - file_name, - ], stdout=PIPE, stdin=PIPE) +# This configuration was initially generated by the +# 'sshuttle --sudoers-no-modify' command. - process.stdin.write(content.encode()) +Cmnd_Alias {cmd_alias} = /usr/bin/env PYTHONPATH={dist_packages} {executable} * - streamdata = process.communicate()[0] - sys.stdout.write(streamdata.decode("ASCII")) - returncode = process.returncode +{user_name} ALL=NOPASSWD: {cmd_alias} +""" - if returncode: - log('Failed updating sudoers file.') - debug1(streamdata) - exit(returncode) - else: - log('Success, sudoers file update.') - exit(0) + return template -def sudoers(user_name=None, no_modify=None, file_name=None): +def sudoers(user_name=None): user_name = user_name or getpass.getuser() content = build_config(user_name) - - if no_modify: - sys.stdout.write(content) - exit(0) - else: - sys.stdout.write(warning_msg) - save_config(content, file_name) + sys.stdout.write(content) + exit(0) diff --git a/tests/client/test_firewall.py b/tests/client/test_firewall.py index d249361ee..7f702b372 100644 --- a/tests/client/test_firewall.py +++ b/tests/client/test_firewall.py @@ -1,12 +1,16 @@ import io +import os from socket import AF_INET, AF_INET6 from unittest.mock import Mock, patch, call + +import pytest + import sshuttle.firewall def setup_daemon(): - stdin = io.StringIO(u"""ROUTES + stdin = io.BytesIO(u"""ROUTES {inet},24,0,1.2.3.0,8000,9000 {inet},32,1,1.2.3.66,8080,8080 {inet6},64,0,2404:6800:4004:80c::,0,0 @@ -15,9 +19,9 @@ def setup_daemon(): {inet},1.2.3.33 {inet6},2404:6800:4004:80c::33 PORTS 1024,1025,1026,1027 -GO 1 - 0x01 +GO 1 - - 0x01 12345 HOST 1.2.3.3,existing -""".format(inet=AF_INET, inet6=AF_INET6)) +""".format(inet=AF_INET, inet6=AF_INET6).encode('ASCII')) stdout = Mock() return stdin, stdout @@ -59,6 +63,21 @@ def test_rewrite_etc_hosts(tmpdir): assert orig_hosts.computehash() == new_hosts.computehash() +@patch('os.link') +@patch('os.rename') +def test_rewrite_etc_hosts_no_overwrite(mock_link, mock_rename, tmpdir): + mock_link.side_effect = OSError + mock_rename.side_effect = OSError + + with pytest.raises(OSError): + os.link('/test_from', '/test_to') + + with pytest.raises(OSError): + os.rename('/test_from', '/test_to') + + test_rewrite_etc_hosts(tmpdir) + + def test_subnet_weight(): subnets = [ (AF_INET, 16, 0, '192.168.0.0', 0, 0), @@ -108,9 +127,9 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts): ] assert stdout.mock_calls == [ - call.write('READY test\n'), + call.write(b'READY test\n'), call.flush(), - call.write('STARTED\n'), + call.write(b'STARTED\n'), call.flush() ] assert mock_setup_daemon.mock_calls == [call()] @@ -123,19 +142,22 @@ def test_main(mock_get_method, mock_setup_daemon, mock_rewrite_etc_hosts): [(AF_INET6, u'2404:6800:4004:80c::33')], AF_INET6, [(AF_INET6, 64, False, u'2404:6800:4004:80c::', 0, 0), - (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], + (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], True, None, + None, '0x01'), call().setup_firewall( 1025, 1027, [(AF_INET, u'1.2.3.33')], AF_INET, [(AF_INET, 24, False, u'1.2.3.0', 8000, 9000), - (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], + (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], True, None, + None, '0x01'), - call().restore_firewall(1024, AF_INET6, True, None), - call().restore_firewall(1025, AF_INET, True, None), + call().wait_for_firewall_ready(12345), + call().restore_firewall(1024, AF_INET6, True, None, None), + call().restore_firewall(1025, AF_INET, True, None, None), ] diff --git a/tests/client/test_helpers.py b/tests/client/test_helpers.py index 45e7ea516..bfbb145ab 100644 --- a/tests/client/test_helpers.py +++ b/tests/client/test_helpers.py @@ -143,7 +143,7 @@ def test_resolvconf_nameservers(mock_open): @patch('sshuttle.helpers.open', create=True) -def test_resolvconf_random_nameserver(mock_open): +def test_get_random_nameserver(mock_open): mock_open.return_value = io.StringIO(u""" # Generated by NetworkManager search pri @@ -156,7 +156,7 @@ def test_resolvconf_random_nameserver(mock_open): nameserver 2404:6800:4004:80c::3 nameserver 2404:6800:4004:80c::4 """) - ns = sshuttle.helpers.resolvconf_random_nameserver(False) + ns = sshuttle.helpers.get_random_nameserver() assert ns in [ (AF_INET, u'192.168.1.1'), (AF_INET, u'192.168.2.1'), (AF_INET, u'192.168.3.1'), (AF_INET, u'192.168.4.1'), @@ -192,5 +192,4 @@ def test_family_ip_tuple(): def test_family_to_string(): assert sshuttle.helpers.family_to_string(AF_INET) == "AF_INET" assert sshuttle.helpers.family_to_string(AF_INET6) == "AF_INET6" - expected = 'AddressFamily.AF_UNIX' - assert sshuttle.helpers.family_to_string(socket.AF_UNIX) == expected + assert isinstance(sshuttle.helpers.family_to_string(socket.AF_UNIX), str) diff --git a/tests/client/test_methods_nat.py b/tests/client/test_methods_nat.py index 6f7ae482b..fa969aa83 100644 --- a/tests/client/test_methods_nat.py +++ b/tests/client/test_methods_nat.py @@ -81,7 +81,7 @@ def test_assert_features(): def test_firewall_command(): method = get_method('nat') - assert not method.firewall_command("somthing") + assert not method.firewall_command("something") @patch('sshuttle.methods.nat.ipt') @@ -101,6 +101,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 80, 80)], False, None, + None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ @@ -118,14 +119,14 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', '--dest', u'2404:6800:4004:80c::33', '-p', 'udp', '--dport', '53', '--to-ports', '1026'), - call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', - '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-p', 'tcp', '--dport', '80:80'), call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'REDIRECT', '--dest', u'2404:6800:4004:80c::/64', '-p', 'tcp', - '--to-ports', '1024') + '--to-ports', '1024'), + call(AF_INET6, 'nat', '-A', 'sshuttle-1024', '-j', 'RETURN', + '-m', 'addrtype', '--dst-type', 'LOCAL') ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() @@ -142,6 +143,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], True, None, + None, '0x01') assert str(excinfo.value) == 'UDP not supported by nat method_name' assert mock_ipt_chain_exists.mock_calls == [] @@ -155,6 +157,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): (AF_INET, 32, True, u'1.2.3.66', 8080, 8080)], False, None, + None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'nat', 'sshuttle-1025') @@ -171,18 +174,18 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', '--dest', u'1.2.3.33', '-p', 'udp', '--dport', '53', '--to-ports', '1027'), - call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', - '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-p', 'tcp', '--dport', '8080:8080'), call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'REDIRECT', '--dest', u'1.2.3.0/24', '-p', 'tcp', '--dport', '8000:9000', - '--to-ports', '1025') + '--to-ports', '1025'), + call(AF_INET, 'nat', '-A', 'sshuttle-1025', '-j', 'RETURN', + '-m', 'addrtype', '--dst-type', 'LOCAL'), ] mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() - method.restore_firewall(1025, AF_INET, False, None) + method.restore_firewall(1025, AF_INET, False, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'nat', 'sshuttle-1025') ] @@ -197,7 +200,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() - method.restore_firewall(1025, AF_INET6, False, None) + method.restore_firewall(1025, AF_INET6, False, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'nat', 'sshuttle-1025') ] diff --git a/tests/client/test_methods_pf.py b/tests/client/test_methods_pf.py index dca5c5119..5cd61faba 100644 --- a/tests/client/test_methods_pf.py +++ b/tests/client/test_methods_pf.py @@ -92,7 +92,7 @@ def test_assert_features(): @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') - assert not method.firewall_command("somthing") + assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, @@ -115,7 +115,7 @@ def test_firewall_command_darwin(mock_pf_get_dev, mock_ioctl, mock_stdout): @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') - assert not method.firewall_command("somthing") + assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, @@ -138,7 +138,7 @@ def test_firewall_command_freebsd(mock_pf_get_dev, mock_ioctl, mock_stdout): @patch('sshuttle.methods.pf.pf_get_dev') def test_firewall_command_openbsd(mock_pf_get_dev, mock_ioctl, mock_stdout): method = get_method('pf') - assert not method.firewall_command("somthing") + assert not method.firewall_command("something") command = "QUERY_PF_NAT %d,%d,%s,%d,%s,%d\n" % ( AF_INET, socket.IPPROTO_TCP, @@ -187,6 +187,7 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, + None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), @@ -227,6 +228,7 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, + None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] @@ -241,6 +243,7 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, + None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), @@ -270,7 +273,7 @@ def test_setup_firewall_darwin(mock_pf_get_dev, mock_ioctl, mock_pfctl): mock_ioctl.reset_mock() mock_pfctl.reset_mock() - method.restore_firewall(1025, AF_INET, False, None) + method.restore_firewall(1025, AF_INET, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), @@ -302,6 +305,7 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl, (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, + None, '0x01') assert mock_pfctl.mock_calls == [ @@ -335,6 +339,7 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl, (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, + None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] @@ -349,6 +354,7 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl, (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, + None, '0x01') assert mock_ioctl.mock_calls == [ call(mock_pf_get_dev(), 0xC4704433, ANY), @@ -376,8 +382,8 @@ def test_setup_firewall_freebsd(mock_pf_get_dev, mock_ioctl, mock_pfctl, mock_ioctl.reset_mock() mock_pfctl.reset_mock() - method.restore_firewall(1025, AF_INET, False, None) - method.restore_firewall(1024, AF_INET6, False, None) + method.restore_firewall(1025, AF_INET, False, None, None) + method.restore_firewall(1024, AF_INET6, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), @@ -408,11 +414,12 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], False, None, + None, '0x01') assert mock_ioctl.mock_calls == [ - call(mock_pf_get_dev(), 0xcd60441a, ANY), - call(mock_pf_get_dev(), 0xcd60441a, ANY), + call(mock_pf_get_dev(), 0xcd50441a, ANY), + call(mock_pf_get_dev(), 0xcd50441a, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), @@ -445,6 +452,7 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, + None, '0x01') assert str(excinfo.value) == 'UDP not supported by pf method_name' assert mock_pf_get_dev.mock_calls == [] @@ -459,10 +467,11 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl): (AF_INET, 32, True, u'1.2.3.66', 80, 80)], False, None, + None, '0x01') assert mock_ioctl.mock_calls == [ - call(mock_pf_get_dev(), 0xcd60441a, ANY), - call(mock_pf_get_dev(), 0xcd60441a, ANY), + call(mock_pf_get_dev(), 0xcd50441a, ANY), + call(mock_pf_get_dev(), 0xcd50441a, ANY), ] assert mock_pfctl.mock_calls == [ call('-s Interfaces -i lo -v'), @@ -484,8 +493,8 @@ def test_setup_firewall_openbsd(mock_pf_get_dev, mock_ioctl, mock_pfctl): mock_ioctl.reset_mock() mock_pfctl.reset_mock() - method.restore_firewall(1025, AF_INET, False, None) - method.restore_firewall(1024, AF_INET6, False, None) + method.restore_firewall(1025, AF_INET, False, None, None) + method.restore_firewall(1024, AF_INET6, False, None, None) assert mock_ioctl.mock_calls == [] assert mock_pfctl.mock_calls == [ call('-a sshuttle-1025 -F all'), diff --git a/tests/client/test_methods_tproxy.py b/tests/client/test_methods_tproxy.py index 994a9075f..44184e54a 100644 --- a/tests/client/test_methods_tproxy.py +++ b/tests/client/test_methods_tproxy.py @@ -78,7 +78,7 @@ def test_assert_features(): def test_firewall_command(): method = get_method('tproxy') - assert not method.firewall_command("somthing") + assert not method.firewall_command("something") @patch('sshuttle.methods.tproxy.ipt') @@ -98,6 +98,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): (AF_INET6, 128, True, u'2404:6800:4004:80c::101f', 8080, 8080)], True, None, + None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'mangle', 'sshuttle-m-1024'), @@ -122,6 +123,13 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): call(AF_INET6, 'mangle', '-I', 'OUTPUT', '1', '-j', 'sshuttle-m-1024'), call(AF_INET6, 'mangle', '-I', 'PREROUTING', '1', '-j', 'sshuttle-t-1024'), + call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'MARK', + '--set-mark', '0x01', '--dest', u'2404:6800:4004:80c::33/32', + '-m', 'udp', '-p', 'udp', '--dport', '53'), + call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'TPROXY', + '--tproxy-mark', '0x01', + '--dest', u'2404:6800:4004:80c::33/32', + '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1026'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', @@ -133,13 +141,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): '-j', 'sshuttle-d-1024', '-m', 'tcp', '-p', 'tcp'), call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-m', 'socket', '-j', 'sshuttle-d-1024', '-m', 'udp', '-p', 'udp'), - call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'MARK', - '--set-mark', '0x01', '--dest', u'2404:6800:4004:80c::33/32', - '-m', 'udp', '-p', 'udp', '--dport', '53'), - call(AF_INET6, 'mangle', '-A', 'sshuttle-t-1024', '-j', 'TPROXY', - '--tproxy-mark', '0x01', - '--dest', u'2404:6800:4004:80c::33/32', - '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1026'), call(AF_INET6, 'mangle', '-A', 'sshuttle-m-1024', '-j', 'RETURN', '--dest', u'2404:6800:4004:80c::101f/128', '-m', 'tcp', '-p', 'tcp', '--dport', '8080:8080'), @@ -172,7 +173,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() - method.restore_firewall(1025, AF_INET6, True, None) + method.restore_firewall(1025, AF_INET6, True, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET6, 'mangle', 'sshuttle-m-1025'), call(AF_INET6, 'mangle', 'sshuttle-t-1025'), @@ -201,6 +202,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): (AF_INET, 32, True, u'1.2.3.66', 80, 80)], True, None, + None, '0x01') assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'mangle', 'sshuttle-m-1025'), @@ -225,6 +227,12 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): call(AF_INET, 'mangle', '-I', 'OUTPUT', '1', '-j', 'sshuttle-m-1025'), call(AF_INET, 'mangle', '-I', 'PREROUTING', '1', '-j', 'sshuttle-t-1025'), + call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'MARK', + '--set-mark', '0x01', '--dest', u'1.2.3.33/32', + '-m', 'udp', '-p', 'udp', '--dport', '53'), + call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'TPROXY', + '--tproxy-mark', '0x01', '--dest', u'1.2.3.33/32', + '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1027'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'RETURN', '-m', 'addrtype', '--dst-type', 'LOCAL'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', @@ -236,12 +244,6 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): '-j', 'sshuttle-d-1025', '-m', 'tcp', '-p', 'tcp'), call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-m', 'socket', '-j', 'sshuttle-d-1025', '-m', 'udp', '-p', 'udp'), - call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'MARK', - '--set-mark', '0x01', '--dest', u'1.2.3.33/32', - '-m', 'udp', '-p', 'udp', '--dport', '53'), - call(AF_INET, 'mangle', '-A', 'sshuttle-t-1025', '-j', 'TPROXY', - '--tproxy-mark', '0x01', '--dest', u'1.2.3.33/32', - '-m', 'udp', '-p', 'udp', '--dport', '53', '--on-port', '1027'), call(AF_INET, 'mangle', '-A', 'sshuttle-m-1025', '-j', 'RETURN', '--dest', u'1.2.3.66/32', '-m', 'tcp', '-p', 'tcp', '--dport', '80:80'), @@ -270,7 +272,7 @@ def test_setup_firewall(mock_ipt_chain_exists, mock_ipt): mock_ipt_chain_exists.reset_mock() mock_ipt.reset_mock() - method.restore_firewall(1025, AF_INET, True, None) + method.restore_firewall(1025, AF_INET, True, None, None) assert mock_ipt_chain_exists.mock_calls == [ call(AF_INET, 'mangle', 'sshuttle-m-1025'), call(AF_INET, 'mangle', 'sshuttle-t-1025'), diff --git a/tests/client/test_options.py b/tests/client/test_options.py index 6f86a8a7f..0bb6d79c9 100644 --- a/tests/client/test_options.py +++ b/tests/client/test_options.py @@ -1,5 +1,6 @@ import socket from argparse import ArgumentTypeError as Fatal +from unittest.mock import patch import pytest @@ -27,6 +28,23 @@ _ip6_swidths = (48, 64, 96, 115, 128) +def _mock_getaddrinfo(host, *_): + return { + "example.com": [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('2606:2800:220:1:248:1893:25c8:1946', 0, 0, 0)), + (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('93.184.216.34', 0)), + ], + "my.local": [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('::1', 0, 0, 0)), + (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('127.0.0.1', 0)), + ], + "*.blogspot.com": [ + (socket.AF_INET6, socket.SOCK_STREAM, 0, '', ('2404:6800:4004:821::2001', 0, 0, 0)), + (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('142.251.42.129', 0)), + ], + }.get(host, []) + + def test_parse_subnetport_ip4(): for ip_repr, ip in _ip4_reprs.items(): assert sshuttle.options.parse_subnetport(ip_repr) \ @@ -105,3 +123,86 @@ def test_parse_subnetport_ip6_with_mask_and_port(): def test_convert_arg_line_to_args_skips_comments(): parser = sshuttle.options.MyArgumentParser() assert parser.convert_arg_line_to_args("# whatever something") == [] + + +@patch('sshuttle.options.socket.getaddrinfo', side_effect=_mock_getaddrinfo) +def test_parse_subnetport_host(mock_getaddrinfo): + assert set(sshuttle.options.parse_subnetport('example.com')) \ + == set([ + (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 0, 0), + (socket.AF_INET, '93.184.216.34', 32, 0, 0), + ]) + assert set(sshuttle.options.parse_subnetport('my.local')) \ + == set([ + (socket.AF_INET6, '::1', 128, 0, 0), + (socket.AF_INET, '127.0.0.1', 32, 0, 0), + ]) + assert set(sshuttle.options.parse_subnetport('*.blogspot.com')) \ + == set([ + (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 0, 0), + (socket.AF_INET, '142.251.42.129', 32, 0, 0), + ]) + + +@patch('sshuttle.options.socket.getaddrinfo', side_effect=_mock_getaddrinfo) +def test_parse_subnetport_host_with_port(mock_getaddrinfo): + assert set(sshuttle.options.parse_subnetport('example.com:80')) \ + == set([ + (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 80, 80), + (socket.AF_INET, '93.184.216.34', 32, 80, 80), + ]) + assert set(sshuttle.options.parse_subnetport('example.com:80-90')) \ + == set([ + (socket.AF_INET6, '2606:2800:220:1:248:1893:25c8:1946', 128, 80, 90), + (socket.AF_INET, '93.184.216.34', 32, 80, 90), + ]) + assert set(sshuttle.options.parse_subnetport('my.local:445')) \ + == set([ + (socket.AF_INET6, '::1', 128, 445, 445), + (socket.AF_INET, '127.0.0.1', 32, 445, 445), + ]) + assert set(sshuttle.options.parse_subnetport('my.local:445-450')) \ + == set([ + (socket.AF_INET6, '::1', 128, 445, 450), + (socket.AF_INET, '127.0.0.1', 32, 445, 450), + ]) + assert set(sshuttle.options.parse_subnetport('*.blogspot.com:80')) \ + == set([ + (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 80), + (socket.AF_INET, '142.251.42.129', 32, 80, 80), + ]) + assert set(sshuttle.options.parse_subnetport('*.blogspot.com:80-90')) \ + == set([ + (socket.AF_INET6, '2404:6800:4004:821::2001', 128, 80, 90), + (socket.AF_INET, '142.251.42.129', 32, 80, 90), + ]) + + +def test_parse_namespace(): + valid_namespaces = [ + 'my_namespace', + 'my.namespace', + 'my_namespace_with_underscore', + 'MyNamespace', + '@my_namespace', + 'my.long_namespace.with.multiple.dots', + '@my.long_namespace.with.multiple.dots', + 'my.Namespace.With.Mixed.Case', + ] + + for namespace in valid_namespaces: + assert sshuttle.options.parse_namespace(namespace) == namespace + + invalid_namespaces = [ + '', + '123namespace', + 'my-namespace', + 'my_namespace!', + '.my_namespace', + 'my_namespace.', + 'my..namespace', + ] + + for namespace in invalid_namespaces: + with pytest.raises(Fatal, match="'.*' is not a valid namespace name."): + sshuttle.options.parse_namespace(namespace) diff --git a/tox.ini b/tox.ini index 84ed81de7..1c95a3546 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,16 @@ [tox] downloadcache = {toxworkdir}/cache/ envlist = - py36, - py37, py38, py39, + py310, [testenv] basepython = - py36: python3.6 - py37: python3.7 - py38: python3.8 py39: python3.9 + py310: python3.10 + py311: python3.11 + py312: python3.12 commands = pip install -e . # actual flake8 test diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..38eca2c42 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1425 @@ +version = 1 +requires-python = ">=3.9, <4.0" +resolution-markers = [ + "python_full_version < '3.10'", + "python_full_version >= '3.10'", +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, +] + +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/3c/adaf39ce1fb4afdd21b611e3d530b183bb7759c9b673d60db0e347fd4439/beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", size = 619516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3b/4ba3f93ac8d90410423fdd31d7541ada9bcee1df32fb90d26de41ed40e1d/black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32", size = 1629419 }, + { url = "https://files.pythonhosted.org/packages/b4/02/0bde0485146a8a5e694daed47561785e8b77a0466ccc1f3e485d5ef2925e/black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da", size = 1461080 }, + { url = "https://files.pythonhosted.org/packages/52/0e/abdf75183c830eaca7589144ff96d49bce73d7ec6ad12ef62185cc0f79a2/black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7", size = 1766886 }, + { url = "https://files.pythonhosted.org/packages/dc/a6/97d8bb65b1d8a41f8a6736222ba0a334db7b7b77b8023ab4568288f23973/black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9", size = 1419404 }, + { url = "https://files.pythonhosted.org/packages/7e/4f/87f596aca05c3ce5b94b8663dbfe242a12843caaa82dd3f85f1ffdc3f177/black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0", size = 1614372 }, + { url = "https://files.pythonhosted.org/packages/e7/d0/2c34c36190b741c59c901e56ab7f6e54dad8df05a6272a9747ecef7c6036/black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299", size = 1442865 }, + { url = "https://files.pythonhosted.org/packages/21/d4/7518c72262468430ead45cf22bd86c883a6448b9eb43672765d69a8f1248/black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096", size = 1749699 }, + { url = "https://files.pythonhosted.org/packages/58/db/4f5beb989b547f79096e035c4981ceb36ac2b552d0ac5f2620e941501c99/black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2", size = 1428028 }, + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988 }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985 }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816 }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860 }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673 }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190 }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926 }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613 }, + { url = "https://files.pythonhosted.org/packages/d3/b6/ae7507470a4830dbbfe875c701e84a4a5fb9183d1497834871a715716a92/black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0", size = 1628593 }, + { url = "https://files.pythonhosted.org/packages/24/c1/ae36fa59a59f9363017ed397750a0cd79a470490860bc7713967d89cdd31/black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f", size = 1460000 }, + { url = "https://files.pythonhosted.org/packages/ac/b6/98f832e7a6c49aa3a464760c67c7856363aa644f2f3c74cf7d624168607e/black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e", size = 1765963 }, + { url = "https://files.pythonhosted.org/packages/ce/e9/2cb0a017eb7024f70e0d2e9bdb8c5a5b078c5740c7f8816065d06f04c557/black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355", size = 1419419 }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, +] + +[[package]] +name = "bump2version" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/2a/688aca6eeebfe8941235be53f4da780c6edee05dbbea5d7abaa3aab6fad2/bump2version-1.0.1.tar.gz", hash = "sha256:762cb2bfad61f4ec8e2bdf452c7c267416f8c70dd9ecb1653fd0bbb01fa936e6", size = 36236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/e3/fa60c47d7c344533142eb3af0b73234ef8ea3fb2da742ab976b947e717df/bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", size = 22030 }, +] + +[[package]] +name = "cattrs" +version = "24.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/65/af6d57da2cb32c076319b7489ae0958f746949d407109e3ccf4d115f147c/cattrs-24.1.2.tar.gz", hash = "sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85", size = 426462 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl", hash = "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0", size = 66446 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 }, + { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 }, + { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 }, + { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 }, + { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 }, + { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 }, + { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 }, + { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 }, + { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 }, + { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 }, + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 }, + { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 }, + { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 }, + { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 }, + { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 }, + { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 }, + { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 }, + { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 }, + { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 }, + { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 }, + { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 }, + { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/67/81dc41ec8f548c365d04a29f1afd492d3176b372c33e47fa2a45a01dc13a/coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8", size = 208345 }, + { url = "https://files.pythonhosted.org/packages/33/43/17f71676016c8829bde69e24c852fef6bd9ed39f774a245d9ec98f689fa0/coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879", size = 208775 }, + { url = "https://files.pythonhosted.org/packages/86/25/c6ff0775f8960e8c0840845b723eed978d22a3cd9babd2b996e4a7c502c6/coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe", size = 237925 }, + { url = "https://files.pythonhosted.org/packages/b0/3d/5f5bd37046243cb9d15fff2c69e498c2f4fe4f9b42a96018d4579ed3506f/coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674", size = 235835 }, + { url = "https://files.pythonhosted.org/packages/b5/f1/9e6b75531fe33490b910d251b0bf709142e73a40e4e38a3899e6986fe088/coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb", size = 236966 }, + { url = "https://files.pythonhosted.org/packages/4f/bc/aef5a98f9133851bd1aacf130e754063719345d2fb776a117d5a8d516971/coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c", size = 236080 }, + { url = "https://files.pythonhosted.org/packages/eb/d0/56b4ab77f9b12aea4d4c11dc11cdcaa7c29130b837eb610639cf3400c9c3/coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c", size = 234393 }, + { url = "https://files.pythonhosted.org/packages/0d/77/28ef95c5d23fe3dd191a0b7d89c82fea2c2d904aef9315daf7c890e96557/coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e", size = 235536 }, + { url = "https://files.pythonhosted.org/packages/29/62/18791d3632ee3ff3f95bc8599115707d05229c72db9539f208bb878a3d88/coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425", size = 211063 }, + { url = "https://files.pythonhosted.org/packages/fc/57/b3878006cedfd573c963e5c751b8587154eb10a61cc0f47a84f85c88a355/coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa", size = 211955 }, + { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, + { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, + { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, + { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, + { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, + { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, + { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, + { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, + { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, + { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, + { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, + { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, + { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, + { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, + { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, + { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, + { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, + { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, + { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, + { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, + { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, + { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, + { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, + { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, + { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, + { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, + { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, + { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, + { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, + { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, + { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, + { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, + { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, + { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, + { url = "https://files.pythonhosted.org/packages/6c/eb/cf062b1c3dbdcafd64a2a154beea2e4aa8e9886c34e41f53fa04925c8b35/coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d", size = 208343 }, + { url = "https://files.pythonhosted.org/packages/95/42/4ebad0ab065228e29869a060644712ab1b0821d8c29bfefa20c2118c9e19/coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929", size = 208769 }, + { url = "https://files.pythonhosted.org/packages/44/9f/421e84f7f9455eca85ff85546f26cbc144034bb2587e08bfc214dd6e9c8f/coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87", size = 237553 }, + { url = "https://files.pythonhosted.org/packages/c9/c4/a2c4f274bcb711ed5db2ccc1b851ca1c45f35ed6077aec9d6c61845d80e3/coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c", size = 235473 }, + { url = "https://files.pythonhosted.org/packages/e0/10/a3d317e38e5627b06debe861d6c511b1611dd9dc0e2a47afbe6257ffd341/coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2", size = 236575 }, + { url = "https://files.pythonhosted.org/packages/4d/49/51cd991b56257d2e07e3d5cb053411e9de5b0f4e98047167ec05e4e19b55/coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd", size = 235690 }, + { url = "https://files.pythonhosted.org/packages/f7/87/631e5883fe0a80683a1f20dadbd0f99b79e17a9d8ea9aff3a9b4cfe50b93/coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73", size = 234040 }, + { url = "https://files.pythonhosted.org/packages/7c/34/edd03f6933f766ec97dddd178a7295855f8207bb708dbac03777107ace5b/coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86", size = 235048 }, + { url = "https://files.pythonhosted.org/packages/ee/1e/d45045b7d3012fe518c617a57b9f9396cdaebe6455f1b404858b32c38cdd/coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31", size = 211085 }, + { url = "https://files.pythonhosted.org/packages/df/ea/086cb06af14a84fe773b86aa140892006a906c5ec947e609ceb6a93f6257/coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57", size = 211965 }, + { url = "https://files.pythonhosted.org/packages/7a/7f/05818c62c7afe75df11e0233bd670948d68b36cdbf2a339a095bc02624a8/coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf", size = 200558 }, + { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "44.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, + { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, + { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, + { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, + { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, + { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, + { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, + { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, + { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, + { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, + { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, + { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, + { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, + { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, + { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, + { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, + { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, + { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, + { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, + { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, + { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, + { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, + { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, + { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, + { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, +] + +[[package]] +name = "docstring-to-markdown" +version = "0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/ad/6a66abd14676619bd56f6b924c96321a2e2d7d86558841d94a30023eec53/docstring-to-markdown-0.15.tar.gz", hash = "sha256:e146114d9c50c181b1d25505054a8d0f7a476837f0da2c19f07e06eaed52b73d", size = 29246 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/cf/4eee59f6c4111b3e80cc32cf6bac483a90646f5c8693e84496c9855e8e38/docstring_to_markdown-0.15-py3-none-any.whl", hash = "sha256:27afb3faedba81e34c33521c32bbd258d7fbb79eedf7d29bc4e81080e854aec0", size = 21640 }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "flake8" +version = "7.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/16/3f2a0bb700ad65ac9663262905a025917c020a3f92f014d2ba8964b4602c/flake8-7.1.2.tar.gz", hash = "sha256:c586ffd0b41540951ae41af572e6790dbd49fc12b3aa2541685d253d9bd504bd", size = 48119 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/f8/08d37b2cd89da306e3520bd27f8a85692122b42b56c0c2c3784ff09c022f/flake8-7.1.2-py2.py3-none-any.whl", hash = "sha256:1cbc62e65536f65e6d754dfe6f1bada7f5cf392d6f5db3c2b85892466c3e7c1a", size = 57745 }, +] + +[[package]] +name = "furo" +version = "2024.8.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/e2/d351d69a9a9e4badb4a5be062c2d0e87bd9e6c23b5e57337fef14bef34c8/furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01", size = 1661506 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/48/e791a7ed487dbb9729ef32bb5d1af16693d8925f4366befef54119b2e576/furo-2024.8.6-py3-none-any.whl", hash = "sha256:6cd97c58b47813d3619e63e9081169880fbe331f0ca883c871ff1f3f11814f5c", size = 341333 }, +] + +[[package]] +name = "id" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jedi-language-server" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cattrs" }, + { name = "docstring-to-markdown" }, + { name = "jedi" }, + { name = "lsprotocol" }, + { name = "pygls" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/ca/97ec6c7acc8e7718816283d3140ebd9601781d731be753c8d0eb97fca541/jedi_language_server-0.44.0.tar.gz", hash = "sha256:276536bd00e64e65753d54cd35237d62730daffd65292dc8510d3063ebaefe4d", size = 32564 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/04/52ec51105a38c6807162c9c48c006b754885574eb52a7ed0bf10369c1a30/jedi_language_server-0.44.0-py3-none-any.whl", hash = "sha256:17619fac5faf7111036c0b01d460c4eb848ce8df1af8208d85c255db34ec2eff", size = 31794 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "jinja2" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, +] + +[[package]] +name = "keyring" +version = "25.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 }, +] + +[[package]] +name = "lsprotocol" +version = "2023.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/f6/6e80484ec078d0b50699ceb1833597b792a6c695f90c645fbaf54b947e6f/lsprotocol-2023.0.1.tar.gz", hash = "sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d", size = 69434 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/37/2351e48cb3309673492d3a8c59d407b75fb6630e560eb27ecd4da03adc9a/lsprotocol-2023.0.1-py3-none-any.whl", hash = "sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2", size = 70826 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "10.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3b/7fa1fe835e2e93fd6d7b52b2f95ae810cf5ba133e1845f726f5a992d62c2/more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", size = 125009 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038 }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433 }, + { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472 }, + { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424 }, + { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450 }, + { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765 }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701 }, + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/5a/fa/79cf41a55b682794abe71372151dbbf856e3008f6767057229e6649d294a/mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078", size = 10737129 }, + { url = "https://files.pythonhosted.org/packages/d3/33/dd8feb2597d648de29e3da0a8bf4e1afbda472964d2a4a0052203a6f3594/mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba", size = 9856335 }, + { url = "https://files.pythonhosted.org/packages/e4/b5/74508959c1b06b96674b364ffeb7ae5802646b32929b7701fc6b18447592/mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5", size = 11611935 }, + { url = "https://files.pythonhosted.org/packages/6c/53/da61b9d9973efcd6507183fdad96606996191657fe79701b2c818714d573/mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b", size = 12365827 }, + { url = "https://files.pythonhosted.org/packages/c1/72/965bd9ee89540c79a25778cc080c7e6ef40aa1eeac4d52cec7eae6eb5228/mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2", size = 12541924 }, + { url = "https://files.pythonhosted.org/packages/46/d0/f41645c2eb263e6c77ada7d76f894c580c9ddb20d77f0c24d34273a4dab2/mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980", size = 9271176 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "nh3" +version = "0.2.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678 }, + { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774 }, + { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012 }, + { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619 }, + { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384 }, + { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908 }, + { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180 }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747 }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908 }, + { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133 }, + { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328 }, + { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020 }, + { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878 }, + { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460 }, + { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369 }, + { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036 }, + { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712 }, + { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559 }, + { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591 }, + { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670 }, + { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093 }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623 }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pycodestyle" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/aa/210b2c9aedd8c1cbeea31a50e42050ad56187754b34eb214c46709445801/pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521", size = 39232 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/d8/a211b3f85e99a0daa2ddec96c949cac6824bd305b040571b82a03dd62636/pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", size = 31284 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 }, +] + +[[package]] +name = "pygls" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/b9/41d173dad9eaa9db9c785a85671fc3d68961f08d67706dc2e79011e10b5c/pygls-1.3.1.tar.gz", hash = "sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018", size = 45527 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/19/b74a10dd24548e96e8c80226cbacb28b021bc3a168a7d2709fb0d0185348/pygls-1.3.1-py3-none-any.whl", hash = "sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e", size = 56031 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pylsp-mypy" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy" }, + { name = "python-lsp-server" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/4d/9683a57f2e8b9263910ef497a99d88622f4fb1c158decb867fd40a41bfdd/pylsp_mypy-0.7.0.tar.gz", hash = "sha256:e94f531d4ce523222c2af7471abe396cfeb4cc3c4b181d54462fb6d553e1e0b3", size = 18529 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/7d/324859fa4af565db32ff8d924fd10dd49922756736be12d783be3813ffc8/pylsp_mypy-0.7.0-py3-none-any.whl", hash = "sha256:756377d05d251d2e31d1963397654149b9c1ea5b0ba1aedd74adef76decd32e9", size = 12232 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "python-lsp-jsonrpc" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ujson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/b6/fd92e2ea4635d88966bb42c20198df1a981340f07843b5e3c6694ba3557b/python-lsp-jsonrpc-1.1.2.tar.gz", hash = "sha256:4688e453eef55cd952bff762c705cedefa12055c0aec17a06f595bcc002cc912", size = 15298 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/d9/656659d5b5d5f402b2b174cd0ba9bc827e07ce3c0bf88da65424baf64af8/python_lsp_jsonrpc-1.1.2-py3-none-any.whl", hash = "sha256:7339c2e9630ae98903fdaea1ace8c47fba0484983794d6aafd0bd8989be2b03c", size = 8805 }, +] + +[[package]] +name = "python-lsp-server" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-to-markdown" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jedi" }, + { name = "pluggy" }, + { name = "python-lsp-jsonrpc" }, + { name = "ujson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/0f/3d63c5f37edca529a2a003a30add97dcce67a83a99dd932528f623aa1df9/python_lsp_server-1.12.2.tar.gz", hash = "sha256:fea039a36b3132774d0f803671184cf7dde0c688e7b924f23a6359a66094126d", size = 115054 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/e7/28010a326ef591e1409daf9d57a47de94156c147ad1befe74d8196d82729/python_lsp_server-1.12.2-py3-none-any.whl", hash = "sha256:750116459449184ba20811167cdf96f91296ae12f1f65ebd975c5c159388111b", size = 74773 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "ruff" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, + { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, + { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, + { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, + { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, + { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, + { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, + { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, + { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, + { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, + { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, + { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, + { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, + { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, + { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, + { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/7b/af302bebf22c749c56c9c3e8ae13190b5b5db37a33d9068652e8f73b7089/snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", size = 86699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, +] + +[[package]] +name = "soupsieve" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.10'" }, + { name = "babel", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.10'" }, + { name = "imagesize", marker = "python_full_version < '3.10'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2", marker = "python_full_version < '3.10'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "requests", marker = "python_full_version < '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.10'" }, + { name = "babel", marker = "python_full_version >= '3.10'" }, + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.10'" }, + { name = "imagesize", marker = "python_full_version >= '3.10'" }, + { name = "jinja2", marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "requests", marker = "python_full_version >= '3.10'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.10'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125 }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496 }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071 }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + +[[package]] +name = "sshuttle" +version = "1.3.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "bump2version" }, + { name = "flake8" }, + { name = "jedi-language-server" }, + { name = "pyflakes" }, + { name = "pylsp-mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "python-lsp-server" }, + { name = "ruff" }, + { name = "twine" }, +] +docs = [ + { name = "furo" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.1.0" }, + { name = "bump2version", specifier = ">=1.0.1,<2.0.0" }, + { name = "flake8", specifier = ">=7.0.0,<8.0.0" }, + { name = "jedi-language-server", specifier = ">=0.44.0" }, + { name = "pyflakes", specifier = ">=3.2.0,<4.0.0" }, + { name = "pylsp-mypy", specifier = ">=0.7.0" }, + { name = "pytest", specifier = ">=8.0.1,<9.0.0" }, + { name = "pytest-cov", specifier = ">=4.1,<7.0" }, + { name = "python-lsp-server", specifier = ">=1.12.2" }, + { name = "ruff", specifier = ">=0.11.2" }, + { name = "twine", specifier = ">=5,<7" }, +] +docs = [ + { name = "furo", specifier = "==2024.8.6" }, + { name = "sphinx", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = "==8.1.3" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "twine" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "ujson" +version = "5.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/00/3110fd566786bfa542adb7932d62035e0c0ef662a8ff6544b6643b3d6fd7/ujson-5.10.0.tar.gz", hash = "sha256:b3cd8f3c5d8c7738257f1018880444f7b7d9b66232c64649f562d7ba86ad4bc1", size = 7154885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/91/91678e49a9194f527e60115db84368c237ac7824992224fac47dcb23a5c6/ujson-5.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2601aa9ecdbee1118a1c2065323bda35e2c5a2cf0797ef4522d485f9d3ef65bd", size = 55354 }, + { url = "https://files.pythonhosted.org/packages/de/2f/1ed8c9b782fa4f44c26c1c4ec686d728a4865479da5712955daeef0b2e7b/ujson-5.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:348898dd702fc1c4f1051bc3aacbf894caa0927fe2c53e68679c073375f732cf", size = 51808 }, + { url = "https://files.pythonhosted.org/packages/51/bf/a3a38b2912288143e8e613c6c4c3f798b5e4e98c542deabf94c60237235f/ujson-5.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22cffecf73391e8abd65ef5f4e4dd523162a3399d5e84faa6aebbf9583df86d6", size = 51995 }, + { url = "https://files.pythonhosted.org/packages/b4/6d/0df8f7a6f1944ba619d93025ce468c9252aa10799d7140e07014dfc1a16c/ujson-5.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26b0e2d2366543c1bb4fbd457446f00b0187a2bddf93148ac2da07a53fe51569", size = 53566 }, + { url = "https://files.pythonhosted.org/packages/d5/ec/370741e5e30d5f7dc7f31a478d5bec7537ce6bfb7f85e72acefbe09aa2b2/ujson-5.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:caf270c6dba1be7a41125cd1e4fc7ba384bf564650beef0df2dd21a00b7f5770", size = 58499 }, + { url = "https://files.pythonhosted.org/packages/fe/29/72b33a88f7fae3c398f9ba3e74dc2e5875989b25f1c1f75489c048a2cf4e/ujson-5.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a245d59f2ffe750446292b0094244df163c3dc96b3ce152a2c837a44e7cda9d1", size = 997881 }, + { url = "https://files.pythonhosted.org/packages/70/5c/808fbf21470e7045d56a282cf5e85a0450eacdb347d871d4eb404270ee17/ujson-5.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94a87f6e151c5f483d7d54ceef83b45d3a9cca7a9cb453dbdbb3f5a6f64033f5", size = 1140631 }, + { url = "https://files.pythonhosted.org/packages/8f/6a/e1e8281408e6270d6ecf2375af14d9e2f41c402ab6b161ecfa87a9727777/ujson-5.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29b443c4c0a113bcbb792c88bea67b675c7ca3ca80c3474784e08bba01c18d51", size = 1043511 }, + { url = "https://files.pythonhosted.org/packages/cb/ca/e319acbe4863919ec62498bc1325309f5c14a3280318dca10fe1db3cb393/ujson-5.10.0-cp310-cp310-win32.whl", hash = "sha256:c18610b9ccd2874950faf474692deee4223a994251bc0a083c114671b64e6518", size = 38626 }, + { url = "https://files.pythonhosted.org/packages/78/ec/dc96ca379de33f73b758d72e821ee4f129ccc32221f4eb3f089ff78d8370/ujson-5.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:924f7318c31874d6bb44d9ee1900167ca32aa9b69389b98ecbde34c1698a250f", size = 42076 }, + { url = "https://files.pythonhosted.org/packages/23/ec/3c551ecfe048bcb3948725251fb0214b5844a12aa60bee08d78315bb1c39/ujson-5.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a5b366812c90e69d0f379a53648be10a5db38f9d4ad212b60af00bd4048d0f00", size = 55353 }, + { url = "https://files.pythonhosted.org/packages/8d/9f/4731ef0671a0653e9f5ba18db7c4596d8ecbf80c7922dd5fe4150f1aea76/ujson-5.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:502bf475781e8167f0f9d0e41cd32879d120a524b22358e7f205294224c71126", size = 51813 }, + { url = "https://files.pythonhosted.org/packages/1f/2b/44d6b9c1688330bf011f9abfdb08911a9dc74f76926dde74e718d87600da/ujson-5.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b91b5d0d9d283e085e821651184a647699430705b15bf274c7896f23fe9c9d8", size = 51988 }, + { url = "https://files.pythonhosted.org/packages/29/45/f5f5667427c1ec3383478092a414063ddd0dfbebbcc533538fe37068a0a3/ujson-5.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:129e39af3a6d85b9c26d5577169c21d53821d8cf68e079060602e861c6e5da1b", size = 53561 }, + { url = "https://files.pythonhosted.org/packages/26/21/a0c265cda4dd225ec1be595f844661732c13560ad06378760036fc622587/ujson-5.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f77b74475c462cb8b88680471193064d3e715c7c6074b1c8c412cb526466efe9", size = 58497 }, + { url = "https://files.pythonhosted.org/packages/28/36/8fde862094fd2342ccc427a6a8584fed294055fdee341661c78660f7aef3/ujson-5.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ec0ca8c415e81aa4123501fee7f761abf4b7f386aad348501a26940beb1860f", size = 997877 }, + { url = "https://files.pythonhosted.org/packages/90/37/9208e40d53baa6da9b6a1c719e0670c3f474c8fc7cc2f1e939ec21c1bc93/ujson-5.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ab13a2a9e0b2865a6c6db9271f4b46af1c7476bfd51af1f64585e919b7c07fd4", size = 1140632 }, + { url = "https://files.pythonhosted.org/packages/89/d5/2626c87c59802863d44d19e35ad16b7e658e4ac190b0dead17ff25460b4c/ujson-5.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57aaf98b92d72fc70886b5a0e1a1ca52c2320377360341715dd3933a18e827b1", size = 1043513 }, + { url = "https://files.pythonhosted.org/packages/2f/ee/03662ce9b3f16855770f0d70f10f0978ba6210805aa310c4eebe66d36476/ujson-5.10.0-cp311-cp311-win32.whl", hash = "sha256:2987713a490ceb27edff77fb184ed09acdc565db700ee852823c3dc3cffe455f", size = 38616 }, + { url = "https://files.pythonhosted.org/packages/3e/20/952dbed5895835ea0b82e81a7be4ebb83f93b079d4d1ead93fcddb3075af/ujson-5.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:f00ea7e00447918ee0eff2422c4add4c5752b1b60e88fcb3c067d4a21049a720", size = 42071 }, + { url = "https://files.pythonhosted.org/packages/e8/a6/fd3f8bbd80842267e2d06c3583279555e8354c5986c952385199d57a5b6c/ujson-5.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:98ba15d8cbc481ce55695beee9f063189dce91a4b08bc1d03e7f0152cd4bbdd5", size = 55642 }, + { url = "https://files.pythonhosted.org/packages/a8/47/dd03fd2b5ae727e16d5d18919b383959c6d269c7b948a380fdd879518640/ujson-5.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d2edbf1556e4f56e50fab7d8ff993dbad7f54bac68eacdd27a8f55f433578e", size = 51807 }, + { url = "https://files.pythonhosted.org/packages/25/23/079a4cc6fd7e2655a473ed9e776ddbb7144e27f04e8fc484a0fb45fe6f71/ujson-5.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6627029ae4f52d0e1a2451768c2c37c0c814ffc04f796eb36244cf16b8e57043", size = 51972 }, + { url = "https://files.pythonhosted.org/packages/04/81/668707e5f2177791869b624be4c06fb2473bf97ee33296b18d1cf3092af7/ujson-5.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8ccb77b3e40b151e20519c6ae6d89bfe3f4c14e8e210d910287f778368bb3d1", size = 53686 }, + { url = "https://files.pythonhosted.org/packages/bd/50/056d518a386d80aaf4505ccf3cee1c40d312a46901ed494d5711dd939bc3/ujson-5.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3caf9cd64abfeb11a3b661329085c5e167abbe15256b3b68cb5d914ba7396f3", size = 58591 }, + { url = "https://files.pythonhosted.org/packages/fc/d6/aeaf3e2d6fb1f4cfb6bf25f454d60490ed8146ddc0600fae44bfe7eb5a72/ujson-5.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6e32abdce572e3a8c3d02c886c704a38a1b015a1fb858004e03d20ca7cecbb21", size = 997853 }, + { url = "https://files.pythonhosted.org/packages/f8/d5/1f2a5d2699f447f7d990334ca96e90065ea7f99b142ce96e85f26d7e78e2/ujson-5.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a65b6af4d903103ee7b6f4f5b85f1bfd0c90ba4eeac6421aae436c9988aa64a2", size = 1140689 }, + { url = "https://files.pythonhosted.org/packages/f2/2c/6990f4ccb41ed93744aaaa3786394bca0875503f97690622f3cafc0adfde/ujson-5.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:604a046d966457b6cdcacc5aa2ec5314f0e8c42bae52842c1e6fa02ea4bda42e", size = 1043576 }, + { url = "https://files.pythonhosted.org/packages/14/f5/a2368463dbb09fbdbf6a696062d0c0f62e4ae6fa65f38f829611da2e8fdd/ujson-5.10.0-cp312-cp312-win32.whl", hash = "sha256:6dea1c8b4fc921bf78a8ff00bbd2bfe166345f5536c510671bccececb187c80e", size = 38764 }, + { url = "https://files.pythonhosted.org/packages/59/2d/691f741ffd72b6c84438a93749ac57bf1a3f217ac4b0ea4fd0e96119e118/ujson-5.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:38665e7d8290188b1e0d57d584eb8110951a9591363316dd41cf8686ab1d0abc", size = 42211 }, + { url = "https://files.pythonhosted.org/packages/0d/69/b3e3f924bb0e8820bb46671979770c5be6a7d51c77a66324cdb09f1acddb/ujson-5.10.0-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:618efd84dc1acbd6bff8eaa736bb6c074bfa8b8a98f55b61c38d4ca2c1f7f287", size = 55646 }, + { url = "https://files.pythonhosted.org/packages/32/8a/9b748eb543c6cabc54ebeaa1f28035b1bd09c0800235b08e85990734c41e/ujson-5.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38d5d36b4aedfe81dfe251f76c0467399d575d1395a1755de391e58985ab1c2e", size = 51806 }, + { url = "https://files.pythonhosted.org/packages/39/50/4b53ea234413b710a18b305f465b328e306ba9592e13a791a6a6b378869b/ujson-5.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67079b1f9fb29ed9a2914acf4ef6c02844b3153913eb735d4bf287ee1db6e557", size = 51975 }, + { url = "https://files.pythonhosted.org/packages/b4/9d/8061934f960cdb6dd55f0b3ceeff207fcc48c64f58b43403777ad5623d9e/ujson-5.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d0e0ceeb8fe2468c70ec0c37b439dd554e2aa539a8a56365fd761edb418988", size = 53693 }, + { url = "https://files.pythonhosted.org/packages/f5/be/7bfa84b28519ddbb67efc8410765ca7da55e6b93aba84d97764cd5794dbc/ujson-5.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59e02cd37bc7c44d587a0ba45347cc815fb7a5fe48de16bf05caa5f7d0d2e816", size = 58594 }, + { url = "https://files.pythonhosted.org/packages/48/eb/85d465abafb2c69d9699cfa5520e6e96561db787d36c677370e066c7e2e7/ujson-5.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a890b706b64e0065f02577bf6d8ca3b66c11a5e81fb75d757233a38c07a1f20", size = 997853 }, + { url = "https://files.pythonhosted.org/packages/9f/76/2a63409fc05d34dd7d929357b7a45e3a2c96f22b4225cd74becd2ba6c4cb/ujson-5.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:621e34b4632c740ecb491efc7f1fcb4f74b48ddb55e65221995e74e2d00bbff0", size = 1140694 }, + { url = "https://files.pythonhosted.org/packages/45/ed/582c4daba0f3e1688d923b5cb914ada1f9defa702df38a1916c899f7c4d1/ujson-5.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9500e61fce0cfc86168b248104e954fead61f9be213087153d272e817ec7b4f", size = 1043580 }, + { url = "https://files.pythonhosted.org/packages/d7/0c/9837fece153051e19c7bade9f88f9b409e026b9525927824cdf16293b43b/ujson-5.10.0-cp313-cp313-win32.whl", hash = "sha256:4c4fc16f11ac1612f05b6f5781b384716719547e142cfd67b65d035bd85af165", size = 38766 }, + { url = "https://files.pythonhosted.org/packages/d7/72/6cb6728e2738c05bbe9bd522d6fc79f86b9a28402f38663e85a28fddd4a0/ujson-5.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4573fd1695932d4f619928fd09d5d03d917274381649ade4328091ceca175539", size = 42212 }, + { url = "https://files.pythonhosted.org/packages/97/94/50ff2f1b61d668907f20216873640ab19e0eaa77b51e64ee893f6adfb266/ujson-5.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dfef2814c6b3291c3c5f10065f745a1307d86019dbd7ea50e83504950136ed5b", size = 55421 }, + { url = "https://files.pythonhosted.org/packages/0c/b3/3d2ca621d8dbeaf6c5afd0725e1b4bbd465077acc69eff1e9302735d1432/ujson-5.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4734ee0745d5928d0ba3a213647f1c4a74a2a28edc6d27b2d6d5bd9fa4319e27", size = 51816 }, + { url = "https://files.pythonhosted.org/packages/8d/af/5dc103cb4d08f051f82d162a738adb9da488d1e3fafb9fd9290ea3eabf8e/ujson-5.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47ebb01bd865fdea43da56254a3930a413f0c5590372a1241514abae8aa7c76", size = 52023 }, + { url = "https://files.pythonhosted.org/packages/5d/dd/b9a6027ba782b0072bf24a70929e15a58686668c32a37aebfcfaa9e00bdd/ujson-5.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee5e97c2496874acbf1d3e37b521dd1f307349ed955e62d1d2f05382bc36dd5", size = 53622 }, + { url = "https://files.pythonhosted.org/packages/1f/28/bcf6df25c1a9f1989dc2ddc4ac8a80e246857e089f91a9079fd8a0a01459/ujson-5.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7490655a2272a2d0b072ef16b0b58ee462f4973a8f6bbe64917ce5e0a256f9c0", size = 58563 }, + { url = "https://files.pythonhosted.org/packages/9e/82/89404453a102d06d0937f6807c0a7ef2eec68b200b4ce4386127f3c28156/ujson-5.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba17799fcddaddf5c1f75a4ba3fd6441f6a4f1e9173f8a786b42450851bd74f1", size = 998050 }, + { url = "https://files.pythonhosted.org/packages/63/eb/2a4ea07165cad217bc842bb684b053bafa8ffdb818c47911c621e97a33fc/ujson-5.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2aff2985cef314f21d0fecc56027505804bc78802c0121343874741650a4d3d1", size = 1140672 }, + { url = "https://files.pythonhosted.org/packages/72/53/d7bdf6afabeba3ed899f89d993c7f202481fa291d8c5be031c98a181eda4/ujson-5.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ad88ac75c432674d05b61184178635d44901eb749786c8eb08c102330e6e8996", size = 1043577 }, + { url = "https://files.pythonhosted.org/packages/19/b1/75f5f0d18501fd34487e46829de3070724c7b350f1983ba7f07e0986720b/ujson-5.10.0-cp39-cp39-win32.whl", hash = "sha256:2544912a71da4ff8c4f7ab5606f947d7299971bdd25a45e008e467ca638d13c9", size = 38654 }, + { url = "https://files.pythonhosted.org/packages/77/0d/50d2f9238f6d6683ead5ecd32d83d53f093a3c0047ae4c720b6d586cb80d/ujson-5.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:3ff201d62b1b177a46f113bb43ad300b424b7847f9c5d38b1b4ad8f75d4a282a", size = 42134 }, + { url = "https://files.pythonhosted.org/packages/95/53/e5f5e733fc3525e65f36f533b0dbece5e5e2730b760e9beacf7e3d9d8b26/ujson-5.10.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5b6fee72fa77dc172a28f21693f64d93166534c263adb3f96c413ccc85ef6e64", size = 51846 }, + { url = "https://files.pythonhosted.org/packages/59/1f/f7bc02a54ea7b47f3dc2d125a106408f18b0f47b14fc737f0913483ae82b/ujson-5.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:61d0af13a9af01d9f26d2331ce49bb5ac1fb9c814964018ac8df605b5422dcb3", size = 48103 }, + { url = "https://files.pythonhosted.org/packages/1a/3a/d3921b6f29bc744d8d6c56db5f8bbcbe55115fd0f2b79c3c43ff292cc7c9/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecb24f0bdd899d368b715c9e6664166cf694d1e57be73f17759573a6986dd95a", size = 47257 }, + { url = "https://files.pythonhosted.org/packages/f1/04/f4e3883204b786717038064afd537389ba7d31a72b437c1372297cb651ea/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbd8fd427f57a03cff3ad6574b5e299131585d9727c8c366da4624a9069ed746", size = 48468 }, + { url = "https://files.pythonhosted.org/packages/17/cd/9c6547169eb01a22b04cbb638804ccaeb3c2ec2afc12303464e0f9b2ee5a/ujson-5.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beeaf1c48e32f07d8820c705ff8e645f8afa690cca1544adba4ebfa067efdc88", size = 54266 }, + { url = "https://files.pythonhosted.org/packages/70/bf/ecd14d3cf6127f8a990b01f0ad20e257f5619a555f47d707c57d39934894/ujson-5.10.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:baed37ea46d756aca2955e99525cc02d9181de67f25515c468856c38d52b5f3b", size = 42224 }, + { url = "https://files.pythonhosted.org/packages/8d/96/a3a2356ca5a4b67fe32a0c31e49226114d5154ba2464bb1220a93eb383e8/ujson-5.10.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ba43cc34cce49cf2d4bc76401a754a81202d8aa926d0e2b79f0ee258cb15d3a4", size = 51855 }, + { url = "https://files.pythonhosted.org/packages/73/3d/41e78e7500e75eb6b5a7ab06907a6df35603b92ac6f939b86f40e9fe2c06/ujson-5.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac56eb983edce27e7f51d05bc8dd820586c6e6be1c5216a6809b0c668bb312b8", size = 48059 }, + { url = "https://files.pythonhosted.org/packages/be/14/e435cbe5b5189483adbba5fe328e88418ccd54b2b1f74baa4172384bb5cd/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44bd4b23a0e723bf8b10628288c2c7c335161d6840013d4d5de20e48551773b", size = 47238 }, + { url = "https://files.pythonhosted.org/packages/e8/d9/b6f4d1e6bec20a3b582b48f64eaa25209fd70dc2892b21656b273bc23434/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c10f4654e5326ec14a46bcdeb2b685d4ada6911050aa8baaf3501e57024b804", size = 48457 }, + { url = "https://files.pythonhosted.org/packages/23/1c/cfefabb5996e21a1a4348852df7eb7cfc69299143739e86e5b1071c78735/ujson-5.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0de4971a89a762398006e844ae394bd46991f7c385d7a6a3b93ba229e6dac17e", size = 54238 }, + { url = "https://files.pythonhosted.org/packages/af/c4/fa70e77e1c27bbaf682d790bd09ef40e86807ada704c528ef3ea3418d439/ujson-5.10.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e1402f0564a97d2a52310ae10a64d25bcef94f8dd643fcf5d310219d915484f7", size = 42230 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +]