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..8829e02ce --- /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@v6 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + 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@v4 + + # â„šī¸ 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@v4 diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6001397c8..31956860c 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@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2.3.1 + uses: actions/setup-python@v6 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@v7 + 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..074ddd888 --- /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@v6 + - name: Set up Python 3.12 + uses: actions/setup-python@v6 + with: + python-version: 3.12 + - name: Install uv + uses: astral-sh/setup-uv@v7 + 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@v7 + 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@v8 + 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..9444e77ee --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-24.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..53f8803ac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,63 @@ +# Changelog + +## [1.3.2](https://github.com/sshuttle/sshuttle/compare/v1.3.1...v1.3.2) (2025-08-08) + + +### Bug Fixes + +* improve broken pipe (EPIPE) error handling in socket operations ([47c5afe](https://github.com/sshuttle/sshuttle/commit/47c5afe3f5e65d84894b617803e3272a0987f641)) +* improve broken pipe (EPIPE) error handling in socket operations ([514847e](https://github.com/sshuttle/sshuttle/commit/514847e7d86f65be7315f390e20987a9352840ca)) +* Updates sudoers config according to executable ([934fac9](https://github.com/sshuttle/sshuttle/commit/934fac9d6c0f86223e3e7120148d346d9b20c9d0)) + +## [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..285493267 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 pip 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..86c4bdff7 --- /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.10" +dependencies = [] +name = "sshuttle" +version = "1.3.2" +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>=8.0.1,<10.0.0", + "pytest-cov>=4.1,<8.0", + "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==2025.12.19", +] + +[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..9c0f9a848 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,17 +1,30 @@ +[bumpversion] +current_version = 1.3.2 + +[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..f708a9b20 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.2" 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..be122b863 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: - raise Fatal("failed to establish ssh session (1)") + debug3('Error: EPIPE: ' + repr(e)) + raise Fatal("SSH connection lost: broken pipe (server may have terminated unexpectedly)") 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..2342b122f 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,78 @@ 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. + 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 +169,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 +219,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 +267,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 +294,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 +335,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 +381,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..28f47ad3d 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,27 @@ 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 +115,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 +258,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="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="store_true", + "--group", help=""" - (internal use only) + apply all the rules only to this linux group """ ) parser.add_argument( - "--hostwatch", + "--firewall", action="store_true", help=""" (internal use only) """ ) parser.add_argument( - "--sudoers", + "--hostwatch", action="store_true", help=""" - Add sshuttle to the sudoers for this user + (internal use only) """ ) parser.add_argument( "--sudoers-no-modify", action="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..a69fde20d 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,21 @@ def _route_iproute(line): return ipw, int(mask) +def _route_windows(line): + parts = re.split(r'\s+', line.strip()) + if len(parts) < 4: + return None, None + prefix = parts[3] + dest, mask = prefix.split('/') + if mask == "32": + return None, None + for p in ('127.', '0.', '224.', '169.254.'): + if dest.startswith(p): + return None, None + ipw = _ipmatch(dest) + return ipw, int(mask) + + def _list_routes(argv, extract_route): # FIXME: IPv4 only p = ssubprocess.Popen(argv, stdout=ssubprocess.PIPE, env=get_env()) @@ -86,7 +101,7 @@ def _list_routes(argv, extract_route): for line in p.stdout: if not line.strip(): continue - ipw, mask = extract_route(line.decode("ASCII")) + ipw, mask = extract_route(line.decode("ASCII", errors='ignore')) if not ipw: continue width = min(ipw[1], mask) @@ -101,14 +116,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(['netsh', 'interface', 'ipv4', 'show', 'route'], _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 +200,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 +300,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 +330,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 +408,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..492108e68 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,13 +77,16 @@ 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 - else: + if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN): debug3('%s: err was: %s' % (func.__name__, e)) return None + else: + # Re-raise other errors (including EPIPE) so they can be handled + # by the calling function appropriately + raise def _try_peername(sock): @@ -168,19 +170,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 +201,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,8 +221,8 @@ def uwrite(self, buf): return 0 # still connecting self.wsock.setblocking(False) try: - return _nb_clean(os.write, self.wsock.fileno(), buf) - except OSError: + return _nb_clean(self.wsock.send, buf) + except (OSError, socket.error): _, e = sys.exc_info()[:2] if e.errno == errno.EPIPE: debug1('%r: uwrite: got EPIPE' % self) @@ -227,7 +234,7 @@ def uwrite(self, buf): return 0 def write(self, buf): - assert(buf) + assert buf return self.uwrite(buf) def uread(self): @@ -237,11 +244,16 @@ def uread(self): return self.rsock.setblocking(False) try: - return _nb_clean(os.read, self.rsock.fileno(), 65536) - except OSError: + return _nb_clean(self.rsock.recv, 65536) + except (OSError, socket.error): _, e = sys.exc_info()[:2] - self.seterr('uread: %s' % e) - return b('') # unexpected error... we'll call it EOF + if e.errno == errno.EPIPE: + debug1('%r: uread: got EPIPE' % self) + self.noread() + return b('') # treat broken pipe as EOF + else: + self.seterr('uread: %s' % e) + return b('') # unexpected error... we'll call it EOF def fill(self): if self.buf: @@ -373,11 +385,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 +395,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 +411,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 +446,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 +457,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 +474,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..bb6307b7f 100644 --- a/sshuttle/sudoers.py +++ b/sshuttle/sudoers.py @@ -1,71 +1,46 @@ import os import sys import getpass +from pathlib import Path 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 = str(Path(os.path.abspath(__file__)).parent.parent) + 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..122c4248f --- /dev/null +++ b/uv.lock @@ -0,0 +1,1457 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <4.0" + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[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, upload-time = "2024-01-10T00:56:10.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, +] + +[[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, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + +[[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, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[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, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[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, upload-time = "2025-02-04T20:05:01.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015, upload-time = "2025-02-04T20:05:03.729Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, + { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, + { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, + { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, + { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, + { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +] + +[[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, upload-time = "2020-10-07T18:38:40.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/e3/fa60c47d7c344533142eb3af0b73234ef8ea3fb2da742ab976b947e717df/bump2version-1.0.1-py2.py3-none-any.whl", hash = "sha256:37f927ea17cde7ae2d7baf832f8e80ce3777624554a653006c9144f8017fe410", size = 22030, upload-time = "2020-10-07T18:38:38.148Z" }, +] + +[[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, upload-time = "2024-09-22T14:58:36.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl", hash = "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0", size = 66446, upload-time = "2024-09-22T14:58:34.812Z" }, +] + +[[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, upload-time = "2025-01-31T02:16:47.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, +] + +[[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, upload-time = "2024-12-24T18:12:35.43Z" } +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, upload-time = "2024-12-24T18:09:43.671Z" }, + { 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, upload-time = "2024-12-24T18:09:48.113Z" }, + { 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, upload-time = "2024-12-24T18:09:50.845Z" }, + { 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, upload-time = "2024-12-24T18:09:52.078Z" }, + { 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, upload-time = "2024-12-24T18:09:54.575Z" }, + { 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, upload-time = "2024-12-24T18:09:57.324Z" }, + { 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, upload-time = "2024-12-24T18:09:59.794Z" }, + { 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, upload-time = "2024-12-24T18:10:02.357Z" }, + { 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, upload-time = "2024-12-24T18:10:03.678Z" }, + { 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, upload-time = "2024-12-24T18:10:06.197Z" }, + { 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, upload-time = "2024-12-24T18:10:08.848Z" }, + { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479, upload-time = "2024-12-24T18:10:10.044Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790, upload-time = "2024-12-24T18:10:11.323Z" }, + { 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, upload-time = "2024-12-24T18:10:12.838Z" }, + { 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, upload-time = "2024-12-24T18:10:14.101Z" }, + { 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, upload-time = "2024-12-24T18:10:15.512Z" }, + { 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, upload-time = "2024-12-24T18:10:18.369Z" }, + { 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, upload-time = "2024-12-24T18:10:19.743Z" }, + { 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, upload-time = "2024-12-24T18:10:21.139Z" }, + { 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, upload-time = "2024-12-24T18:10:22.382Z" }, + { 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, upload-time = "2024-12-24T18:10:24.802Z" }, + { 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, upload-time = "2024-12-24T18:10:26.124Z" }, + { 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, upload-time = "2024-12-24T18:10:30.027Z" }, + { 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, upload-time = "2024-12-24T18:10:32.679Z" }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205, upload-time = "2024-12-24T18:10:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441, upload-time = "2024-12-24T18:10:37.574Z" }, + { 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, upload-time = "2024-12-24T18:10:38.83Z" }, + { 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, upload-time = "2024-12-24T18:10:44.272Z" }, + { 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, upload-time = "2024-12-24T18:10:45.492Z" }, + { 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, upload-time = "2024-12-24T18:10:47.898Z" }, + { 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, upload-time = "2024-12-24T18:10:50.589Z" }, + { 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, upload-time = "2024-12-24T18:10:52.541Z" }, + { 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, upload-time = "2024-12-24T18:10:53.789Z" }, + { 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, upload-time = "2024-12-24T18:10:55.048Z" }, + { 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, upload-time = "2024-12-24T18:10:57.647Z" }, + { 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, upload-time = "2024-12-24T18:10:59.43Z" }, + { 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, upload-time = "2024-12-24T18:11:00.676Z" }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, + { 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, upload-time = "2024-12-24T18:11:05.834Z" }, + { 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, upload-time = "2024-12-24T18:11:07.064Z" }, + { 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, upload-time = "2024-12-24T18:11:08.374Z" }, + { 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, upload-time = "2024-12-24T18:11:09.831Z" }, + { 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, upload-time = "2024-12-24T18:11:12.03Z" }, + { 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, upload-time = "2024-12-24T18:11:13.372Z" }, + { 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, upload-time = "2024-12-24T18:11:14.628Z" }, + { 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, upload-time = "2024-12-24T18:11:17.672Z" }, + { 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, upload-time = "2024-12-24T18:11:18.989Z" }, + { 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, upload-time = "2024-12-24T18:11:21.507Z" }, + { 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, upload-time = "2024-12-24T18:11:22.774Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[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, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, +] + +[[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, upload-time = "2024-02-21T13:51:09.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/cf/4eee59f6c4111b3e80cc32cf6bac483a90646f5c8693e84496c9855e8e38/docstring_to_markdown-0.15-py3-none-any.whl", hash = "sha256:27afb3faedba81e34c33521c32bbd258d7fbb79eedf7d29bc4e81080e854aec0", size = 21640, upload-time = "2024-02-21T13:51:08.214Z" }, +] + +[[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, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[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, upload-time = "2024-07-12T22:26:00.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "furo" +version = "2025.12.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/20/5f5ad4da6a5a27c80f2ed2ee9aee3f9e36c66e56e21c00fde467b2f8f88f/furo-2025.12.19.tar.gz", hash = "sha256:188d1f942037d8b37cd3985b955839fea62baa1730087dc29d157677c857e2a7", size = 1661473, upload-time = "2025-12-19T17:34:40.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b2/50e9b292b5cac13e9e81272c7171301abc753a60460d21505b606e15cf21/furo-2025.12.19-py3-none-any.whl", hash = "sha256:bb0ead5309f9500130665a26bee87693c41ce4dbdff864dbfb6b0dae4673d24f", size = 339262, upload-time = "2025-12-19T17:34:38.905Z" }, +] + +[[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, upload-time = "2024-12-04T19:53:05.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, +] + +[[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, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[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, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[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, upload-time = "2025-01-20T22:21:30.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971, upload-time = "2025-01-20T22:21:29.177Z" }, +] + +[[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, upload-time = "2023-01-07T11:08:11.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, +] + +[[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, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[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, upload-time = "2024-08-20T03:39:27.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, +] + +[[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, upload-time = "2024-09-27T19:47:09.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187, upload-time = "2024-09-27T19:47:07.14Z" }, +] + +[[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, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jedi-language-server" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cattrs" }, + { name = "docstring-to-markdown" }, + { name = "jedi" }, + { name = "lsprotocol" }, + { name = "pygls" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/63/2ae4466db0d5f723bb141990487a46f1add8f6449995172c7d45e6d37c66/jedi_language_server-0.46.0.tar.gz", hash = "sha256:b9ee7cce6cb2bd6fce11115906db41549d7e97b358e0818a23dca164ed0b961a", size = 34602, upload-time = "2025-10-25T20:45:40.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/06/c29fe15b67db3170e057376386a7643228b6bcea35df68e884475d8a7897/jedi_language_server-0.46.0-py3-none-any.whl", hash = "sha256:c8003c33a36de9998ac1c6e13ebe3d99b11d06046ae6307d75f54c73e9358ea7", size = 33769, upload-time = "2025-10-25T20:45:39.411Z" }, +] + +[[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, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[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, upload-time = "2024-12-25T15:26:45.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, +] + +[[package]] +name = "lsprotocol" +version = "2025.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, +] + +[[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, upload-time = "2023-06-03T06:41:14.443Z" } +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, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[[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, upload-time = "2024-10-18T15:21:54.129Z" } +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, upload-time = "2024-10-18T15:20:51.44Z" }, + { 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, upload-time = "2024-10-18T15:20:52.426Z" }, + { 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, upload-time = "2024-10-18T15:20:53.578Z" }, + { 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, upload-time = "2024-10-18T15:20:55.06Z" }, + { 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, upload-time = "2024-10-18T15:20:55.906Z" }, + { 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, upload-time = "2024-10-18T15:20:57.189Z" }, + { 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, upload-time = "2024-10-18T15:20:58.235Z" }, + { 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, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { 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, upload-time = "2024-10-18T15:21:02.187Z" }, + { 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, upload-time = "2024-10-18T15:21:02.941Z" }, + { 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, upload-time = "2024-10-18T15:21:03.953Z" }, + { 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, upload-time = "2024-10-18T15:21:06.495Z" }, + { 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, upload-time = "2024-10-18T15:21:07.295Z" }, + { 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, upload-time = "2024-10-18T15:21:08.073Z" }, + { 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, upload-time = "2024-10-18T15:21:09.318Z" }, + { 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, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { 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, upload-time = "2024-10-18T15:21:13.777Z" }, + { 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, upload-time = "2024-10-18T15:21:14.822Z" }, + { 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, upload-time = "2024-10-18T15:21:15.642Z" }, + { 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, upload-time = "2024-10-18T15:21:17.133Z" }, + { 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, upload-time = "2024-10-18T15:21:18.064Z" }, + { 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, upload-time = "2024-10-18T15:21:18.859Z" }, + { 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, upload-time = "2024-10-18T15:21:19.671Z" }, + { 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, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { 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, upload-time = "2024-10-18T15:21:24.577Z" }, + { 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, upload-time = "2024-10-18T15:21:25.382Z" }, + { 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, upload-time = "2024-10-18T15:21:26.199Z" }, + { 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, upload-time = "2024-10-18T15:21:27.029Z" }, + { 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, upload-time = "2024-10-18T15:21:27.846Z" }, + { 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, upload-time = "2024-10-18T15:21:28.744Z" }, + { 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, upload-time = "2024-10-18T15:21:29.545Z" }, + { 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, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { 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, upload-time = "2024-10-18T15:21:33.625Z" }, + { 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, upload-time = "2024-10-18T15:21:34.611Z" }, + { 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, upload-time = "2024-10-18T15:21:35.398Z" }, + { 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, upload-time = "2024-10-18T15:21:36.231Z" }, + { 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, upload-time = "2024-10-18T15:21:37.073Z" }, + { 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, upload-time = "2024-10-18T15:21:37.932Z" }, + { 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, upload-time = "2024-10-18T15:21:39.799Z" }, + { 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, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[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, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[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, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[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, upload-time = "2025-01-14T16:22:47.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038, upload-time = "2025-01-14T16:22:46.014Z" }, +] + +[[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, upload-time = "2025-02-05T03:50:34.655Z" } +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, upload-time = "2025-02-05T03:49:29.145Z" }, + { 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, upload-time = "2025-02-05T03:49:16.986Z" }, + { 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, upload-time = "2025-02-05T03:49:46.908Z" }, + { 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, upload-time = "2025-02-05T03:50:05.89Z" }, + { 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, upload-time = "2025-02-05T03:49:33.56Z" }, + { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload-time = "2025-02-05T03:49:38.981Z" }, + { 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, upload-time = "2025-02-05T03:50:17.287Z" }, + { 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, upload-time = "2025-02-05T03:49:51.21Z" }, + { 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, upload-time = "2025-02-05T03:50:20.885Z" }, + { 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, upload-time = "2025-02-05T03:49:42.408Z" }, + { 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, upload-time = "2025-02-05T03:49:07.707Z" }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload-time = "2025-02-05T03:49:54.581Z" }, + { 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, upload-time = "2025-02-05T03:50:28.25Z" }, + { 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, upload-time = "2025-02-05T03:50:13.411Z" }, + { 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, upload-time = "2025-02-05T03:50:31.421Z" }, + { 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, upload-time = "2025-02-05T03:48:48.705Z" }, + { 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, upload-time = "2025-02-05T03:49:03.628Z" }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" }, + { 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, upload-time = "2025-02-05T03:48:55.789Z" }, + { 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, upload-time = "2025-02-05T03:48:44.581Z" }, + { 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, upload-time = "2025-02-05T03:49:25.514Z" }, + { 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, upload-time = "2025-02-05T03:49:57.623Z" }, + { 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, upload-time = "2025-02-05T03:48:52.361Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, +] + +[[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, upload-time = "2023-02-04T12:11:27.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695, upload-time = "2023-02-04T12:11:25.002Z" }, +] + +[[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, upload-time = "2025-02-25T13:38:44.619Z" } +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, upload-time = "2025-02-25T13:37:56.063Z" }, + { 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, upload-time = "2025-02-25T13:37:58.419Z" }, + { 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, upload-time = "2025-02-25T13:38:01.017Z" }, + { 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, upload-time = "2025-02-25T13:38:02.617Z" }, + { 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, upload-time = "2025-02-25T13:38:04.402Z" }, + { 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, upload-time = "2025-02-25T13:38:06.693Z" }, + { 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, upload-time = "2025-02-25T13:38:10.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747, upload-time = "2025-02-25T13:38:12.548Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908, upload-time = "2025-02-25T13:38:14.059Z" }, + { 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, upload-time = "2025-02-25T13:38:16.601Z" }, + { 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, upload-time = "2025-02-25T13:38:18.972Z" }, + { 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, upload-time = "2025-02-25T13:38:20.571Z" }, + { 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, upload-time = "2025-02-25T13:38:22.204Z" }, + { 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, upload-time = "2025-02-25T13:38:25.951Z" }, + { 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, upload-time = "2025-02-25T13:38:28.174Z" }, + { 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, upload-time = "2025-02-25T13:38:30.539Z" }, + { 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, upload-time = "2025-02-25T13:38:32.992Z" }, + { 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, upload-time = "2025-02-25T13:38:35.204Z" }, + { 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, upload-time = "2025-02-25T13:38:37.099Z" }, + { 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, upload-time = "2025-02-25T13:38:38.696Z" }, + { 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, upload-time = "2025-02-25T13:38:40.249Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623, upload-time = "2025-02-25T13:38:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283, upload-time = "2025-02-25T13:38:43.355Z" }, +] + +[[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, upload-time = "2024-11-08T09:47:47.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, +] + +[[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, upload-time = "2024-04-05T09:43:55.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650, upload-time = "2024-04-05T09:43:53.299Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[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, upload-time = "2025-03-19T20:36:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499, upload-time = "2025-03-19T20:36:09.038Z" }, +] + +[[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, upload-time = "2024-04-20T21:34:42.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[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, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygls" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/50/2bfc32f3acbc8941042919b59c9f592291127b55d7331b72e67ce7b62f08/pygls-2.0.0.tar.gz", hash = "sha256:99accd03de1ca76fe1e7e317f0968ebccf7b9955afed6e2e3e188606a20b4f07", size = 55796, upload-time = "2025-10-17T19:22:47.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/09/14feafc13bebb9c85b29b374889c1549d3700cb572f2d43a1bb940d70315/pygls-2.0.0-py3-none-any.whl", hash = "sha256:b4e54bba806f76781017ded8fd07463b98670f959042c44170cd362088b200cc", size = 69533, upload-time = "2025-10-17T19:22:46.63Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pylsp-mypy" +version = "0.7.1" +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/76/16/af7bc6a6b89e22d754823935bc8e82df3b97df5daba37b12ee1627fc8d61/pylsp_mypy-0.7.1.tar.gz", hash = "sha256:4465a9b5f7816f02579f1398e369cae953952c7d2aaa9cab35e7a554e242b766", size = 18807, upload-time = "2026-02-02T16:01:29.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f8/cc15094638b81aaa61ab084dfdaee077eaf4ade9ecdd2a5b35ca0f3735c8/pylsp_mypy-0.7.1-py3-none-any.whl", hash = "sha256:b1cfc7aa9aab1ee21d45ae353b7f6ad2ca044b58d6150e3e5415ac3523e85b26", size = 12604, upload-time = "2026-02-02T16:01:27.494Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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 = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[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, upload-time = "2023-09-23T17:48:30.451Z" } +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, upload-time = "2023-09-23T17:48:28.804Z" }, +] + +[[package]] +name = "python-lsp-server" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "black" }, + { name = "docstring-to-markdown" }, + { name = "jedi" }, + { name = "pluggy" }, + { name = "python-lsp-jsonrpc" }, + { name = "ujson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/b5/b989d41c63390dfc2bf63275ab543b82fed076723d912055e77ccbae1422/python_lsp_server-1.14.0.tar.gz", hash = "sha256:509c445fc667f41ffd3191cb7512a497bf7dd76c14ceb1ee2f6c13ebe71f9a6b", size = 121536, upload-time = "2025-12-06T16:12:20.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/cf/587f913335e3855e0ddca2aee7c3f9d5de2d75a1e23434891e9f74783bcd/python_lsp_server-1.14.0-py3-none-any.whl", hash = "sha256:a71a917464effc48f4c70363f90b8520e5e3ba8201428da80b97a7ceb259e32a", size = 77060, upload-time = "2025-12-06T16:12:19.46Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, + { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[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, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[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, upload-time = "2024-07-08T15:00:57.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[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, upload-time = "2023-05-01T04:11:33.229Z" } +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, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[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, upload-time = "2022-01-10T00:52:30.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, +] + +[[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, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[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, upload-time = "2022-08-13T16:22:46.976Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221, upload-time = "2022-08-13T16:22:44.457Z" }, +] + +[[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, upload-time = "2021-11-16T18:38:38.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002, upload-time = "2021-11-16T18:38:34.792Z" }, +] + +[[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, upload-time = "2024-08-13T13:39:12.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186, upload-time = "2024-08-13T13:39:10.986Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +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, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[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, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[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, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[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, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[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, upload-time = "2019-01-21T16:10:16.347Z" } +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, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[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, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[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, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "sshuttle" +version = "1.3.2" +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" }, +] + +[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,<10.0.0" }, + { name = "pytest-cov", specifier = ">=4.1,<8.0" }, + { name = "python-lsp-server", specifier = ">=1.12.2" }, + { name = "ruff", specifier = ">=0.11.2" }, + { name = "twine", specifier = ">=5,<7" }, +] +docs = [ + { name = "furo", specifier = "==2025.12.19" }, + { 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, upload-time = "2024-11-27T22:38:36.873Z" } +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, upload-time = "2024-11-27T22:37:54.956Z" }, + { 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, upload-time = "2024-11-27T22:37:56.698Z" }, + { 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, upload-time = "2024-11-27T22:37:57.63Z" }, + { 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, upload-time = "2024-11-27T22:37:59.344Z" }, + { 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, upload-time = "2024-11-27T22:38:00.429Z" }, + { 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, upload-time = "2024-11-27T22:38:02.094Z" }, + { 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, upload-time = "2024-11-27T22:38:03.206Z" }, + { 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, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { 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, upload-time = "2024-11-27T22:38:07.731Z" }, + { 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, upload-time = "2024-11-27T22:38:09.384Z" }, + { 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, upload-time = "2024-11-27T22:38:10.329Z" }, + { 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, upload-time = "2024-11-27T22:38:11.443Z" }, + { 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, upload-time = "2024-11-27T22:38:13.099Z" }, + { 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, upload-time = "2024-11-27T22:38:14.766Z" }, + { 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, upload-time = "2024-11-27T22:38:15.843Z" }, + { 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, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { 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, upload-time = "2024-11-27T22:38:21.659Z" }, + { 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, upload-time = "2024-11-27T22:38:22.693Z" }, + { 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, upload-time = "2024-11-27T22:38:24.367Z" }, + { 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, upload-time = "2024-11-27T22:38:26.081Z" }, + { 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, upload-time = "2024-11-27T22:38:27.921Z" }, + { 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, upload-time = "2024-11-27T22:38:29.591Z" }, + { 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, upload-time = "2024-11-27T22:38:30.639Z" }, + { 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, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { 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/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "ujson" +version = "5.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/ee/45c7c1f9268b0fecdd68f9ada490bc09632b74f5f90a9be759e51a746ddc/ujson-5.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:38051f36423f084b909aaadb3b41c9c6a2958e86956ba21a8489636911e87504", size = 56145, upload-time = "2026-03-11T22:17:49.409Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dc/ed181dbfb2beee598e91280c6903ba71e10362b051716317e2d3664614bb/ujson-5.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:457fabc2700a8e6ddb85bc5a1d30d3345fe0d3ec3ee8161a4e032ec585801dfa", size = 53839, upload-time = "2026-03-11T22:17:50.973Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d8/eb9ef42c660f431deeedc2e1b09c4ba29aa22818a439ddda7da6ae23ddfa/ujson-5.12.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57930ac9519099b852e190d2c04b1fb5d97ea128db33bce77ed874eccb4c7f09", size = 57844, upload-time = "2026-03-11T22:17:53.029Z" }, + { url = "https://files.pythonhosted.org/packages/68/37/0b586d079d3f2a5be5aa58ab5c423cbb4fae2ee4e65369c87aa74ac7e113/ujson-5.12.0-cp310-cp310-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:9b3b86ec3e818f3dd3e13a9de628e88a9990f4af68ecb0b12dd3de81227f0a26", size = 59923, upload-time = "2026-03-11T22:17:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/6a4b69eb397502767f438b5a2b4c066dccc9e3b263115f5ee07510250fc7/ujson-5.12.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e76a4daff214ae33ab959494962c93918cb44714ea3e3f748b14aa37f8a87", size = 57427, upload-time = "2026-03-11T22:17:55.317Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4b/ae118440a72e85e68ee8dd26cfc47ea7857954a3341833cde9da7dc40ca3/ujson-5.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e584d0cdd37cac355aca52ed788d1a2d939d6837e2870d3b70e585db24025a50", size = 1037301, upload-time = "2026-03-11T22:17:56.427Z" }, + { url = "https://files.pythonhosted.org/packages/c2/76/834caa7905f65d3a695e4f5ff8d5d4a98508e396a9e8ab0739ab4fe2d422/ujson-5.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0fe9128e75c6aa6e9ae06c1408d6edd9179a2fef0fe6d9cda3166b887eba521d", size = 1196664, upload-time = "2026-03-11T22:17:58.061Z" }, + { url = "https://files.pythonhosted.org/packages/f2/33/1f3c1543c1d3f18c54bb3f8c1e74314fd6ad3c1aa375f01433e89a86bfa6/ujson-5.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3ed5cb149892141b1e77ef312924a327f2cc718b34247dae346ed66329e1b8be", size = 1089668, upload-time = "2026-03-11T22:17:59.617Z" }, + { url = "https://files.pythonhosted.org/packages/db/52/07d9da456a78296f61893b9d2bbfb2512f4233394748aae80b8d08c7d96e/ujson-5.12.0-cp310-cp310-win32.whl", hash = "sha256:973b7d7145b1ac553a7466a64afa8b31ec2693d7c7fff6a755059e0a2885dfd2", size = 39644, upload-time = "2026-03-11T22:18:01.212Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e5/c1de3041672fa1ab97aae0f0b9f4e30a9b15d4104c734d5627779206c878/ujson-5.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:1d072a403d82aef8090c6d4f728e3a727dfdba1ad3b7fa3a052c3ecbd37e73cb", size = 43875, upload-time = "2026-03-11T22:18:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/8b/49/714a9240d9e6bd86c9684a72f100a0005459165fb2b0f6bf1a1156be0b9f/ujson-5.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:55ede2a7a051b3b7e71a394978a098d71b3783e6b904702ff45483fad434ae2d", size = 38563, upload-time = "2026-03-11T22:18:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/fd22e2f6766bae934d3050517ca47d463016bd8688508d1ecc1baa18a7ad/ujson-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58a11cb49482f1a095a2bd9a1d81dd7c8fb5d2357f959ece85db4e46a825fd00", size = 56139, upload-time = "2026-03-11T22:18:04.591Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fd/6839adff4fc0164cbcecafa2857ba08a6eaeedd7e098d6713cb899a91383/ujson-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3cf13facf6f77c283af0e1713e5e8c47a0fe295af81326cb3cb4380212e797", size = 53836, upload-time = "2026-03-11T22:18:05.662Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b0/0c19faac62d68ceeffa83a08dc3d71b8462cf5064d0e7e0b15ba19898dad/ujson-5.12.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb94245a715b4d6e24689de12772b85329a1f9946cbf6187923a64ecdea39e65", size = 57851, upload-time = "2026-03-11T22:18:06.744Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/e7fd283788de73b86e99e08256726bb385923249c21dcd306e59d532a1a1/ujson-5.12.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:0fe6b8b8968e11dd9b2348bd508f0f57cf49ab3512064b36bc4117328218718e", size = 59906, upload-time = "2026-03-11T22:18:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3a/b100735a2b43ee6e8fe4c883768e362f53576f964d4ea841991060aeaf35/ujson-5.12.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89e302abd3749f6d6699691747969a5d85f7c73081d5ed7e2624c7bd9721a2ab", size = 57409, upload-time = "2026-03-11T22:18:08.79Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/f97cc20c99ca304662191b883ae13ae02912ca7244710016ba0cb8a5be34/ujson-5.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0727363b05ab05ee737a28f6200dc4078bce6b0508e10bd8aab507995a15df61", size = 1037339, upload-time = "2026-03-11T22:18:10.424Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/53ddeda0ffe1420db2f9999897b3cbb920fbcff1849d1f22b196d0f34785/ujson-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b62cb9a7501e1f5c9ffe190485501349c33e8862dde4377df774e40b8166871f", size = 1196625, upload-time = "2026-03-11T22:18:11.82Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/4c64a6bef522e9baf195dd5be151bc815cd4896c50c6e2489599edcda85f/ujson-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6ec5bf6bc361f2f0f9644907a36ce527715b488988a8df534120e5c34eeda94", size = 1089669, upload-time = "2026-03-11T22:18:13.343Z" }, + { url = "https://files.pythonhosted.org/packages/18/11/8ccb109f5777ec0d9fb826695a9e2ac36ae94c1949fc8b1e4d23a5bd067a/ujson-5.12.0-cp311-cp311-win32.whl", hash = "sha256:006428d3813b87477d72d306c40c09f898a41b968e57b15a7d88454ecc42a3fb", size = 39648, upload-time = "2026-03-11T22:18:14.785Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/87fc4c27b20d5125cff7ce52d17ea7698b22b74426da0df238e3efcb0cf2/ujson-5.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:40aa43a7a3a8d2f05e79900858053d697a88a605e3887be178b43acbcd781161", size = 43876, upload-time = "2026-03-11T22:18:15.768Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/324f0548a8c8c48e3e222eaed15fb6d48c796593002b206b4a28a89e445f/ujson-5.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:561f89cc82deeae82e37d4a4764184926fb432f740a9691563a391b13f7339a4", size = 38553, upload-time = "2026-03-11T22:18:17.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/123ffaac17e45ef2b915e3e3303f8f4ea78bb8d42afad828844e08622b1e/ujson-5.12.0-cp312-cp312-win32.whl", hash = "sha256:2a248750abce1c76fbd11b2e1d88b95401e72819295c3b851ec73399d6849b3d", size = 39773, upload-time = "2026-03-11T22:18:28.244Z" }, + { url = "https://files.pythonhosted.org/packages/b5/20/f3bd2b069c242c2b22a69e033bfe224d1d15d3649e6cd7cc7085bb1412ff/ujson-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:1b5c6ceb65fecd28a1d20d1eba9dbfa992612b86594e4b6d47bb580d2dd6bcb3", size = 44040, upload-time = "2026-03-11T22:18:29.236Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/01b5a0bcded14cd2522b218f2edc3533b0fcbccdea01f3e14a2b699071aa/ujson-5.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:9a5fcbe7b949f2e95c47ea8a80b410fcdf2da61c98553b45a4ee875580418b68", size = 38526, upload-time = "2026-03-11T22:18:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f1/0ef0eeab1db8493e1833c8b440fe32cf7538f7afa6e7f7c7e9f62cef464d/ujson-5.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:15d416440148f3e56b9b244fdaf8a09fcf5a72e4944b8e119f5bf60417a2bfc8", size = 56331, upload-time = "2026-03-11T22:18:31.539Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2f/9159f6f399b3f572d20847a2b80d133e3a03c14712b0da4971a36879fb64/ujson-5.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0dd3676ea0837cd70ea1879765e9e9f6be063be0436de9b3ea4b775caf83654", size = 53910, upload-time = "2026-03-11T22:18:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/a9/f96376818d71495d1a4be19a0ab6acf0cc01dd8826553734c3d4dac685b2/ujson-5.12.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7bbf05c38debc90d1a195b11340cc85cb43ab3e753dc47558a3a84a38cbc72da", size = 57757, upload-time = "2026-03-11T22:18:33.866Z" }, + { url = "https://files.pythonhosted.org/packages/98/8d/dd4a151caac6fdcb77f024fbe7f09d465ebf347a628ed6dd581a0a7f6364/ujson-5.12.0-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:3c2f947e55d3c7cfe124dd4521ee481516f3007d13c6ad4bf6aeb722e190eb1b", size = 59940, upload-time = "2026-03-11T22:18:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/c7/17/0d36c2fee0a8d8dc37b011ccd5bbdcfaff8b8ec2bcfc5be998661cdc935b/ujson-5.12.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ea6206043385343aff0b7da65cf73677f6f5e50de8f1c879e557f4298cac36a", size = 57465, upload-time = "2026-03-11T22:18:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/8c/04/b0ee4a4b643a01ba398441da1e357480595edb37c6c94c508dbe0eb9eb60/ujson-5.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb349dbba57c76eec25e5917e07f35aabaf0a33b9e67fc13d188002500106487", size = 1037236, upload-time = "2026-03-11T22:18:37.743Z" }, + { url = "https://files.pythonhosted.org/packages/2d/08/0e7780d0bbb48fe57ded91f550144bcc99c03b5360bf2886dd0dae0ea8f5/ujson-5.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:937794042342006f707837f38d721426b11b0774d327a2a45c0bd389eb750a87", size = 1196717, upload-time = "2026-03-11T22:18:39.101Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4c/e0e34107715bb4dd2d4dcc1ce244d2f074638837adf38aff85a37506efe4/ujson-5.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ad57654570464eb1b040b5c353dee442608e06cff9102b8fcb105565a44c9ed", size = 1089748, upload-time = "2026-03-11T22:18:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/814f4e2b5374d0d505c254ba4bed43eb25d2d046f19f5fd88555f81a7bd0/ujson-5.12.0-cp313-cp313-win32.whl", hash = "sha256:76bf3e7406cf23a3e1ca6a23fb1fb9ea82f4f6bd226fe226e09146b0194f85dc", size = 39778, upload-time = "2026-03-11T22:18:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/19310d848ebe93315b6cb171277e4ce29f47ef9d46caabd63ff05d5be548/ujson-5.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:15e555c4caca42411270b2ed2b2ebc7b3a42bb04138cef6c956e1f1d49709fe2", size = 44038, upload-time = "2026-03-11T22:18:43.094Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e4/7a39103d7634691601a02bd1ca7268fba4da47ed586365e6ee68168f575a/ujson-5.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bd03472c36fa3a386a6deb887113b9e3fa40efba8203eb4fe786d3c0ccc724f6", size = 38529, upload-time = "2026-03-11T22:18:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/9a8d693254bada62bfea75a507e014afcfdb6b9d047b6f8dd134bfefaf67/ujson-5.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85833bca01aa5cae326ac759276dc175c5fa3f7b3733b7d543cf27f2df12d1ef", size = 56499, upload-time = "2026-03-11T22:18:45.431Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2d/285a83df8176e18dcd675d1a4cff8f7620f003f30903ea43929406e98986/ujson-5.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d22cad98c2a10bbf6aa083a8980db6ed90d4285a841c4de892890c2b28286ef9", size = 53998, upload-time = "2026-03-11T22:18:47.184Z" }, + { url = "https://files.pythonhosted.org/packages/bf/8b/e2f09e16dabfa91f6a84555df34a4329fa7621e92ed054d170b9054b9bb2/ujson-5.12.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99cc80facad240b0c2fb5a633044420878aac87a8e7c348b9486450cba93f27c", size = 57783, upload-time = "2026-03-11T22:18:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/ba1d06f3658a0c36d0ab3869ec3914f202bad0a9bde92654e41516c7bb13/ujson-5.12.0-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:d1831c07bd4dce53c4b666fa846c7eba4b7c414f2e641a4585b7f50b72f502dc", size = 60011, upload-time = "2026-03-11T22:18:49.284Z" }, + { url = "https://files.pythonhosted.org/packages/64/2b/3e322bf82d926d9857206cd5820438d78392d1f523dacecb8bd899952f73/ujson-5.12.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e00cec383eab2406c9e006bd4edb55d284e94bb943fda558326048178d26961", size = 57465, upload-time = "2026-03-11T22:18:50.584Z" }, + { url = "https://files.pythonhosted.org/packages/e9/fd/af72d69603f9885e5136509a529a4f6d88bf652b457263ff96aefcd3ab7d/ujson-5.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f19b3af31d02a2e79c5f9a6deaab0fb3c116456aeb9277d11720ad433de6dfc6", size = 1037275, upload-time = "2026-03-11T22:18:51.998Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a7/a2411ec81aef7872578e56304c3e41b3a544a9809e95c8e1df46923fc40b/ujson-5.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bacbd3c69862478cbe1c7ed4325caedec580d8acf31b8ee1b9a1e02a56295cad", size = 1196758, upload-time = "2026-03-11T22:18:53.548Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/aa18ae175dd03a118555aa14304d4f466f9db61b924c97c6f84388ecacb1/ujson-5.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94c5f1621cbcab83c03be46441f090b68b9f307b6c7ec44d4e3f6d5997383df4", size = 1089760, upload-time = "2026-03-11T22:18:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d4/4b40b67ac7e916ebffc3041ae2320c5c0b8a045300d4c542b6e50930cca5/ujson-5.12.0-cp314-cp314-win32.whl", hash = "sha256:e6369ac293d2cc40d52577e4fa3d75a70c1aae2d01fa3580a34a4e6eff9286b9", size = 41043, upload-time = "2026-03-11T22:18:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/24/38/a1496d2a3428981f2b3a2ffbb4656c2b05be6cc406301d6b10a6445f6481/ujson-5.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:31348a0ffbfc815ce78daac569d893349d85a0b57e1cd2cdbba50b7f333784da", size = 45303, upload-time = "2026-03-11T22:18:57.454Z" }, + { url = "https://files.pythonhosted.org/packages/85/d3/39dbd3159543d9c57ec3a82d36226152cf0d710784894ce5aa24b8220ac1/ujson-5.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:6879aed770557f0961b252648d36f6fdaab41079d37a2296b5649fd1b35608e0", size = 39860, upload-time = "2026-03-11T22:18:58.578Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/9b4dacb177d3509077e50497222d39eec04c8b41edb1471efc764d645237/ujson-5.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ddb08b3c2f9213df1f2e3eb2fbea4963d80ec0f8de21f0b59898e34f3b3d96d", size = 56845, upload-time = "2026-03-11T22:18:59.629Z" }, + { url = "https://files.pythonhosted.org/packages/24/c2/8abffa3be1f3d605c4a62445fab232b3e7681512ce941c6b23014f404d36/ujson-5.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a3ae28f0b209be5af50b54ca3e2123a3de3a57d87b75f1e5aa3d7961e041983", size = 54463, upload-time = "2026-03-11T22:19:00.697Z" }, + { url = "https://files.pythonhosted.org/packages/db/2e/60114a35d1d6796eb428f7affcba00a921831ff604a37d9142c3d8bbe5c5/ujson-5.12.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30ad4359413c8821cc7b3707f7ca38aa8bc852ba3b9c5a759ee2d7740157315", size = 58689, upload-time = "2026-03-11T22:19:01.739Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ad/010925c2116c21ce119f9c2ff18d01f48a19ade3ff4c5795da03ce5829fc/ujson-5.12.0-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:02f93da7a4115e24f886b04fd56df1ee8741c2ce4ea491b7ab3152f744ad8f8e", size = 60618, upload-time = "2026-03-11T22:19:03.101Z" }, + { url = "https://files.pythonhosted.org/packages/9b/74/db7f638bf20282b1dccf454386cbd483faaaed3cdbb9cb27e06f74bb109e/ujson-5.12.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3ff4ede90ed771140caa7e1890de17431763a483c54b3c1f88bd30f0cc1affc0", size = 58151, upload-time = "2026-03-11T22:19:04.175Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7e/3ebaecfa70a2e8ce623db8e21bd5cb05d42a5ef943bcbb3309d71b5de68d/ujson-5.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bf9cc97f05048ac8f3e02cd58f0fe62b901453c24345bfde287f4305dcc31c", size = 1038117, upload-time = "2026-03-11T22:19:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/e073eda7f0036c2973b28db7bb99faba17a932e7b52d801f9bb3e726271f/ujson-5.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2324d9a0502317ffc35d38e153c1b2fa9610ae03775c9d0f8d0cca7b8572b04e", size = 1197434, upload-time = "2026-03-11T22:19:06.92Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/b9a13f058fdd50c746b192c4447ca8d6352e696dcda912ccee10f032ff85/ujson-5.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:50524f4f6a1c839714dbaff5386a1afb245d2d5ec8213a01fbc99cea7307811e", size = 1090401, upload-time = "2026-03-11T22:19:08.383Z" }, + { url = "https://files.pythonhosted.org/packages/c4/37/3d1b4e0076b6e43379600b5229a5993db8a759ff2e1830ea635d876f6644/ujson-5.12.0-cp314-cp314t-win32.whl", hash = "sha256:f7a0430d765f9bda043e6aefaba5944d5f21ec43ff4774417d7e296f61917382", size = 41880, upload-time = "2026-03-11T22:19:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c5/3c2a262a138b9f0014fe1134a6b5fdc2c54245030affbaac2fcbc0632138/ujson-5.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ccbfd94e59aad4a2566c71912b55f0547ac1680bfac25eb138e6703eb3dd434e", size = 46365, upload-time = "2026-03-11T22:19:10.662Z" }, + { url = "https://files.pythonhosted.org/packages/83/40/956dc20b7e00dc0ff3259871864f18dab211837fce3478778bedb3132ac1/ujson-5.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:42d875388fbd091c7ea01edfff260f839ba303038ffb23475ef392012e4d63dd", size = 40398, upload-time = "2026-03-11T22:19:11.666Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/1df8e6217c92e57a1266bf5be750b1dddc126ee96e53fe959d5693503bc6/ujson-5.12.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:8712b61eb1b74a4478cfd1c54f576056199e9f093659334aeb5c4a6b385338e5", size = 44615, upload-time = "2026-03-11T22:19:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/f4a957dddb99bd68c8be91928c0b6fefa7aa8aafc92c93f5d1e8b32f6702/ujson-5.12.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:871c0e5102e47995b0e37e8df7819a894a6c3da0d097545cd1f9f1f7d7079927", size = 52145, upload-time = "2026-03-11T22:19:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/55/6e/50b5cf612de1ca06c7effdc5a5d7e815774dee85a5858f1882c425553b82/ujson-5.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:56ba3f7abbd6b0bb282a544dc38406d1a188d8bb9164f49fdb9c2fee62cb29da", size = 49577, upload-time = "2026-03-11T22:19:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/b6713fa9897774502cd4c2d6955bb4933349f7d84c3aa805531c382a4209/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5a52987a990eb1bae55f9000994f1afdb0326c154fb089992f839ab3c30688", size = 50807, upload-time = "2026-03-11T22:19:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/c0e0f7901180ef80d16f3a4bccb5dc8b01515a717336a62928963a07b80b/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:adf28d13a33f9d750fe7a78fb481cac298fa257d8863d8727b2ea4455ea41235", size = 56972, upload-time = "2026-03-11T22:19:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/02/a9/05d91b4295ea7239151eb08cf240e5a2ba969012fda50bc27bcb1ea9cd71/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51acc750ec7a2df786cdc868fb16fa04abd6269a01d58cf59bafc57978773d8e", size = 52045, upload-time = "2026-03-11T22:19:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/92047d32bf6f2d9db64605fc32e8eb0e0dd68b671eaafc12a464f69c4af4/ujson-5.12.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ab9056d94e5db513d9313b34394f3a3b83e6301a581c28ad67773434f3faccab", size = 44053, upload-time = "2026-03-11T22:19:23.918Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[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, upload-time = "2024-11-10T15:05:20.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630, upload-time = "2024-11-10T15:05:19.275Z" }, +]