pax_global_header00006660000000000000000000000064152460321510014511gustar00rootroot0000000000000052 comment=341e7a4292fe5071e5d66c4322580b32ffb99dc5 py-mine-mcstatus-341e7a4/000077500000000000000000000000001524603215100152625ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/.github/000077500000000000000000000000001524603215100166225ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/.github/CODEOWNERS000066400000000000000000000002121524603215100202100ustar00rootroot00000000000000# Devops & CI workflows .github/dependabot.yml @ItsDrike .github/workflows/** @ItsDrike .pre-commit-config.yaml @ItsDrike py-mine-mcstatus-341e7a4/.github/renovate.json5000066400000000000000000000013761524603215100214340ustar00rootroot00000000000000{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ ":automergeMinor", ":automergePr", ":combinePatchMinorReleases", ":configMigration", ":dependencyDashboard", ":ignoreModulesAndTests", ":prHourlyLimitNone", ":semanticCommitsDisabled", "group:allNonMajor", "mergeConfidence:all-badges", "replacements:all", "schedule:daily", "workarounds:all", ], "labels": ["a: dependencies"], "packageRules": [ { "groupName": "GitHub Actions", "matchManagers": ["github-actions"], "addLabels": ["a: devops"], }, { "groupName": "Python Dependencies", "matchCategories": ["python"], }, ], "lockFileMaintenance": { "enabled": true, }, } py-mine-mcstatus-341e7a4/.github/workflows/000077500000000000000000000000001524603215100206575ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/.github/workflows/main.yml000066400000000000000000000022611524603215100223270ustar00rootroot00000000000000name: CI on: push: branches: - main pull_request: workflow_dispatch: # Cancel already running workflows if new ones are scheduled concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: validation: uses: ./.github/workflows/validation.yml unit-tests: uses: ./.github/workflows/unit-tests.yml # Produce a pull request payload artifact with various data about the # pull-request event (such as the PR number, title, author, ...). # This data is then be picked up by status-embed.yml action. pr_artifact: name: Produce Pull Request payload artifact runs-on: ubuntu-latest steps: - name: Prepare Pull Request Payload artifact id: prepare-artifact if: always() && github.event_name == 'pull_request' continue-on-error: true run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json - name: Upload a Build Artifact if: always() && steps.prepare-artifact.outcome == 'success' continue-on-error: true uses: actions/upload-artifact@v7 with: name: pull-request-payload path: pull_request_payload.json py-mine-mcstatus-341e7a4/.github/workflows/publish.yml000066400000000000000000000075751524603215100230660ustar00rootroot00000000000000--- name: Publish to PyPI on: push: tags: # This pattern is not a typical regular expression, see: # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet - "v*" branches: # Also run on every commit to main. This allows us to test the build & release pipeline and eventually leads to a # Test PyPI release. Unlike with a tag push, this will not release a full PyPI release, nor create a GitHub release. - main permissions: contents: read env: PYTHON_VERSION: "3.14" jobs: build: name: "Build the project" runs-on: ubuntu-latest outputs: version: ${{ steps.check-version.outputs.version }} tagged_release: ${{ steps.check-version.outputs.tagged_release }} steps: - name: Checkout repository uses: actions/checkout@v7 with: # Do a full clone for uv-dynamic-versioning to pick up the git version fetch-depth: 0 - name: Setup uv uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" python-version: ${{ env.PYTHON_VERSION }} activate-environment: true enable-cache: true cache-suffix: "build" - name: Install dependencies run: | uv sync --no-default-groups --group release - name: Check version status id: check-version run: | version="$(hatchling version)" echo "Project version: $version" echo "version=$version" >> "$GITHUB_OUTPUT" # Determine whether we're doing a tagged release e.g. this workflow # was triggered by a git tag ref that matches the project's current # version, so a full PyPI release should be made, alongside all of # the other release steps. If this isn't the case, only a Test PyPI # release will be performed. if [[ "${GITHUB_REF}" == "refs/tags/v${version}" ]]; then echo "This is a new tagged release" echo "tagged_release=true" >> "$GITHUB_OUTPUT" else echo "This is an untagged dev release" echo "tagged_release=false" >> "$GITHUB_OUTPUT" fi - name: Build project for distribution run: uv build --all-packages - name: Upload build files uses: actions/upload-artifact@v7 with: name: "dist" path: "dist/" if-no-files-found: error retention-days: 5 publish-test-pypi: name: "Publish to Test PyPI" # No if condition here, publish both tagged and untagged releases to Test PyPI. needs: build runs-on: ubuntu-latest environment: test-pypi # no approval permissions: # Used to authenticate to Test PyPI via OIDC. id-token: write steps: - name: Download the distribution files from build artifact uses: actions/download-artifact@v8 with: name: "dist" path: "dist/" - name: Upload to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: # the "legacy" in the URL doesn't mean it's deprecated repository-url: https://test.pypi.org/legacy/ # Enable verbose mode for easy debugging verbose: true publish-pypi: name: "Publish to PyPI" if: needs.build.outputs.tagged_release == 'true' # only publish to PyPI on tagged releases needs: build runs-on: ubuntu-latest environment: release # requires approval permissions: # Used to authenticate to PyPI via OIDC. id-token: write steps: - name: Download the distribution files from build artifact uses: actions/download-artifact@v8 with: name: "dist" path: "dist/" # This uses PyPI's trusted publishing, so no token is required - name: Release to PyPI uses: pypa/gh-action-pypi-publish@release/v1 py-mine-mcstatus-341e7a4/.github/workflows/status_embed.yml000066400000000000000000000054761524603215100240750ustar00rootroot00000000000000name: Status Embed on: workflow_run: workflows: - CI types: - completed concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: status_embed: name: Send Status Embed to Discord runs-on: ubuntu-latest steps: # A workflow_run event does not contain all the information # we need for a PR embed. That's why we upload an artifact # with that information in the Lint workflow. - name: Get Pull Request Information id: pr_info if: github.event.workflow_run.event == 'pull_request' run: | curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') [ -z "$DOWNLOAD_URL" ] && exit 1 curl -sSL -H "Authorization: token $GITHUB_TOKEN" -o pull_request_payload.zip $DOWNLOAD_URL || exit 2 unzip -p pull_request_payload.zip > pull_request_payload.json [ -s pull_request_payload.json ] || exit 3 echo "pr_author_login=$(jq -r '.user.login // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT echo "pr_number=$(jq -r '.number // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT echo "pr_title=$(jq -r '.title // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT echo "pr_source=$(jq -r '.head.label // empty' pull_request_payload.json)" >> $GITHUB_OUTPUT env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Send an informational status embed to Discord instead of the # standard embeds that Discord sends. This embed will contain # more information and we can fine tune when we actually want # to send an embed. - name: GitHub Actions Status Embed for Discord uses: SebastiaanZ/github-status-embed-for-discord@v0.3.0 with: # Our GitHub Actions webhook webhook_id: "942940470059892796" webhook_token: ${{ secrets.webhook_token }} # We need to provide the information of the workflow that # triggered this workflow instead of this workflow. workflow_name: ${{ github.event.workflow_run.name }} run_id: ${{ github.event.workflow_run.id }} run_number: ${{ github.event.workflow_run.run_number }} status: ${{ github.event.workflow_run.conclusion }} sha: ${{ github.event.workflow_run.head_sha }} # Now we can use the information extracted in the previous step: pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} pr_number: ${{ steps.pr_info.outputs.pr_number }} pr_title: ${{ steps.pr_info.outputs.pr_title }} pr_source: ${{ steps.pr_info.outputs.pr_source }} py-mine-mcstatus-341e7a4/.github/workflows/unit-tests.yml000066400000000000000000000030051524603215100235170ustar00rootroot00000000000000name: Unit-Tests on: workflow_call jobs: unit-tests: runs-on: ${{ matrix.platform }} strategy: fail-fast: false # Allows for matrix sub-jobs to fail without cancelling the rest matrix: platform: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.10", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup uv uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" python-version: ${{ matrix.python-version }} enable-cache: true cache-suffix: "test-ci" activate-environment: true - name: Install dependencies run: | uv sync --no-default-groups --group test - name: Run pytest shell: bash run: pytest -vv # This job is used purely to provide a workflow status, which we can mark as a # required action in branch protection rules. This is a better option than marking # the tox-test jobs manually, since their names change as the supported python # versions change. This job provides an easy single action that can be marked required. tests-done: needs: [unit-tests] if: always() && !cancelled() runs-on: ubuntu-latest steps: - name: Set status based on required jobs env: RESULTS: ${{ join(needs.*.result, ' ') }} run: | for result in $RESULTS; do if [ "$result" != "success" ]; then exit 1 fi done py-mine-mcstatus-341e7a4/.github/workflows/validation.yml000066400000000000000000000037771524603215100235520ustar00rootroot00000000000000name: Validation on: workflow_call env: PYTHON_VERSION: "3.14" PRE_COMMIT_HOME: "/home/runner/.cache/pre-commit" jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup uv uses: astral-sh/setup-uv@v10.0.1 with: version: "latest" python-version: ${{ env.PYTHON_VERSION }} enable-cache: true cache-suffix: "validation-ci" activate-environment: true - name: Install dependencies run: | # We need the test & docs groups to allow basedpyright to type-check the code in tests/ & docs/ uv sync --no-default-groups --group lint --group test --group docs - name: Get precommit version id: precommit_version run: | PACKAGE_VERSION=$(pip show pre-commit | grep -i "version:" | awk '{print $2}') echo "version=$PACKAGE_VERSION" >> $GITHUB_ENV - name: Pre-commit Environment Caching uses: actions/cache@v6 with: path: ${{ env.PRE_COMMIT_HOME }} key: "precommit-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ steps.precommit_version.outputs.version }}-\ ${{ hashFiles('./.pre-commit-config.yaml') }}" # Restore keys allows us to perform a cache restore even if the full cache key wasn't matched. # That way we still end up saving new cache, but we can still make use of the cache from previous # version. restore-keys: "precommit-${{ runner.os }}-${{ env.PYTHON_VERSION }}-" - name: Run pre-commit hooks run: SKIP=black,isort,ruff,basedpyright,uv-lockfile pre-commit run --all-files - name: Run ruff linter run: ruff check --output-format=github --show-fixes --exit-non-zero-on-fix . - name: Run ruff formatter run: ruff format --diff . - name: Run basedpyright type checker run: basedpyright . - name: Check UV Lockfile run: uv lock --check py-mine-mcstatus-341e7a4/.gitignore000066400000000000000000000035311524603215100172540ustar00rootroot00000000000000.python-version # Created by http://www.gitignore.io ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ venv/ build/ develop-eggs/ dist/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Pyenv local version specification .python-version # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .coverage* !.coveragerc .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm ## Directory-based project format .idea/ /*.iml # if you remove the above rule, at least ignore user-specific stuff: # .idea/workspace.xml # .idea/tasks.xml # .idea/dictionaries # and these sensitive or high-churn files: # .idea/dataSources.ids # .idea/dataSources.xml # .idea/sqlDataSources.xml # .idea/dynamic.xml # and, if using gradle:: # .idea/gradle.xml # .idea/libraries ## File-based project format *.ipr *.iws ## Additional for IntelliJ out/ # generated by mpeltonen/sbt-idea plugin .idea_modules/ # generated by JIRA plugin atlassian-ide-plugin.xml # generated by Crashlytics plugin (for Android Studio and Intellij) com_crashlytics_export_strings.xml ### SublimeText ### # workspace files are user-specific *.sublime-workspace # project files should be checked into the repository, unless a significant # proportion of contributors will probably not be using SublimeText # *.sublime-project #sftp configuration file sftp-config.json ### Visual Studio Code ### .vscode py-mine-mcstatus-341e7a4/.pre-commit-config.yaml000066400000000000000000000033101524603215100215400ustar00rootroot00000000000000ci: autofix_commit_msg: "[pre-commit.ci] auto fixes from pre-commit.com hooks" autofix_prs: true autoupdate_commit_msg: "[pre-commit.ci] pre-commit autoupdate" autoupdate_schedule: weekly submodules: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.5.0 hooks: - id: check-merge-conflict - id: check-toml # For pyproject.toml - id: check-yaml # For workflows - id: end-of-file-fixer - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: mixed-line-ending - id: sort-simple-yaml - repo: local hooks: - id: ruff name: Ruff Linter description: Run ruff checks on the code entry: ruff check --force-exclude language: system types: [python] require_serial: true args: [--fix, --exit-non-zero-on-fix] - repo: local hooks: - id: ruff-ruff name: Ruff Formatter description: Ruf ruff auto-formatter entry: ruff format language: system types: [python] require_serial: true - repo: local hooks: - id: basedpyright name: Basedpyright description: Run basedpyright type checker entry: basedpyright language: system types: [python] pass_filenames: false # basedpyright runs for the entire project, it can't run for single files - repo: local hooks: - id: uv-lockfile name: UV Lockfile description: Check if the UV lockfile is up to date with pyproject.toml entry: uv lock --check language: system files: '^pyproject\.toml$|^uv\.lock$' pass_filenames: false py-mine-mcstatus-341e7a4/.readthedocs.yaml000066400000000000000000000005361524603215100205150ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3.13" jobs: post_create_environment: - python -m pip install uv post_install: - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy sphinx: builder: dirhtml configuration: "docs/conf.py" fail_on_warning: true py-mine-mcstatus-341e7a4/CONTRIBUTING.md000066400000000000000000000002521524603215100175120ustar00rootroot00000000000000## Contributing See [the documentation page](https://mcstatus.readthedocs.io/en/stable/pages/contributing/). The documentation itself is built from the docs/ directory. py-mine-mcstatus-341e7a4/LICENSE000066400000000000000000000261361524603215100162770ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. py-mine-mcstatus-341e7a4/README.md000066400000000000000000000100201524603215100165320ustar00rootroot00000000000000# MCStatus [![discord chat](https://img.shields.io/discord/936788458939224094.svg?logo=Discord)](https://discord.gg/C2wX7zduxC) ![supported python versions](https://img.shields.io/pypi/pyversions/mcstatus.svg) [![current PyPI version](https://img.shields.io/pypi/v/mcstatus.svg)](https://pypi.org/project/mcstatus/) [![Docs](https://img.shields.io/readthedocs/mcstatus?label=Docs)](https://mcstatus.readthedocs.io/) [![CI status](https://github.com/py-mine/mcstatus/actions/workflows/main.yml/badge.svg)](https://github.com/py-mine/mcstatus/actions/workflows/main.yml) Mcstatus provides an API and command line script to fetch publicly available data from Minecraft servers. Specifically, mcstatus retrieves data by using these protocols: [Server List Ping](https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping) and [Query](https://minecraft.wiki/w/Query). Because of mcstatus, you do not need to fully understand those protocols and can instead skip straight to retrieving minecraft server data quickly in your own programs. ## Installation Mcstatus is available on [PyPI](https://pypi.org/project/mcstatus/), and can be installed trivially with: ```bash python3 -m pip install mcstatus ``` ## Usage ### Python API #### Java Edition (1.7+) ```python from mcstatus import JavaServer # You can pass the same address you'd enter into the address field in minecraft into the 'lookup' function # If you know the host and port, you may skip this and use JavaServer("example.org", 1234) server = JavaServer.lookup("example.org:1234") # 'status' is supported by all Minecraft servers that are version 1.7 or higher. # Don't expect the player list to always be complete, because many servers run # plugins that hide this information or limit the number of players returned or even # alter this list to contain fake players for purposes of having a custom message here. status = server.status() print(f"The server has {status.players.online} player(s) online and replied in {status.latency} ms") # 'ping' is supported by all Minecraft servers that are version 1.7 or higher. # It is included in a 'status' call, but is also exposed separate if you do not require the additional info. latency = server.ping() print(f"The server replied in {latency} ms") # 'query' has to be enabled in a server's server.properties file! # It may give more information than a ping, such as a full player list or mod information. query = server.query() print(f"The server has the following players online: {', '.join(query.players.names)}") ``` #### Java Edition (Beta 1.8-1.6) ```python from mcstatus import LegacyServer # You can pass the same address you'd enter into the address field in minecraft into the 'lookup' function # If you know the host and port, you may skip this and use LegacyServer("example.org", 1234) server = LegacyServer.lookup("example.org:1234") # 'status' is supported by all Minecraft servers. status = server.status() print(f"The server has {status.players.online} player(s) online and replied in {status.latency} ms") ``` #### Bedrock Edition ```python from mcstatus import BedrockServer # You can pass the same address you'd enter into the address field in minecraft into the 'lookup' function # If you know the host and port, you may skip this and use BedrockServer("example.org", 19132) server = BedrockServer.lookup("example.org:19132") # 'status' is the only feature that is supported by Bedrock at this time. # In this case status includes players.online, latency, motd, map, gamemode, and players.max. (ex: status.gamemode) status = server.status() print(f"The server has {status.players.online} players online and replied in {status.latency} ms") ``` See the [documentation](https://mcstatus.readthedocs.io) to find what you can do with our library! ### Command Line Interface The mcstatus library includes a simple CLI. Once installed, it can be used through: ```bash python3 -m mcstatus --help ``` ## License Mcstatus is licensed under the Apache 2.0 license. See LICENSE for full text. py-mine-mcstatus-341e7a4/docs/000077500000000000000000000000001524603215100162125ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/docs/Makefile000066400000000000000000000011721524603215100176530ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) py-mine-mcstatus-341e7a4/docs/api/000077500000000000000000000000001524603215100167635ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/docs/api/basic.rst000066400000000000000000000075421524603215100206060ustar00rootroot00000000000000Basic Usage =========== We are small package, so our API is not so big. There are only few classes, which are suggested for a basic usage. Request Classes --------------- These are classes, that you use to send a request to server. .. autoclass:: mcstatus.server.MCServer :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.server.BaseJavaServer :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.server.JavaServer :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.server.LegacyServer :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.server.BedrockServer :members: :undoc-members: :show-inheritance: Response Objects ---------------- These are the classes that you get back after making a request. For Java Server (1.7+) ********************** .. module:: mcstatus.responses.java .. autoclass:: mcstatus.responses.java.JavaStatusResponse() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.java.JavaStatusPlayers() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.java.JavaStatusPlayer() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.java.JavaStatusVersion() :members: :undoc-members: :inherited-members: :exclude-members: build .. module:: mcstatus.responses.query :no-index: .. autoclass:: mcstatus.responses.query.QueryResponse() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.query.QueryPlayers() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.query.QuerySoftware() :members: :undoc-members: :inherited-members: :exclude-members: build Forge Data ********** Forge mod metadata is available on :attr:`status.forge_data `. .. module:: mcstatus.responses.forge :no-index: .. autoclass:: mcstatus.responses.forge.ForgeData() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.forge.ForgeDataChannel() :members: :undoc-members: :inherited-members: :exclude-members: build, decode .. autoclass:: mcstatus.responses.forge.ForgeDataMod() :members: :undoc-members: :inherited-members: :exclude-members: build, decode For Java Server (Beta 1.8-1.6) ****************************** .. versionadded:: 12.1.0 .. versionadded:: 13.0.0 Support for Beta 1.8+ (before was 1.4+) .. module:: mcstatus.responses.legacy :no-index: .. autoclass:: mcstatus.responses.legacy.LegacyStatusResponse() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.legacy.LegacyStatusPlayers() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.legacy.LegacyStatusVersion() :members: :undoc-members: :inherited-members: :exclude-members: build For Bedrock Servers ******************* .. module:: mcstatus.responses.bedrock :no-index: .. autoclass:: mcstatus.responses.bedrock.BedrockStatusResponse() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.bedrock.BedrockStatusPlayers() :members: :undoc-members: :inherited-members: :exclude-members: build .. autoclass:: mcstatus.responses.bedrock.BedrockStatusVersion() :members: :undoc-members: :inherited-members: :exclude-members: build Conclusion ---------- That is all! See also our :doc:`examples `! py-mine-mcstatus-341e7a4/docs/api/internal.rst000066400000000000000000000035471524603215100213420ustar00rootroot00000000000000Internal Data ============= This page contains some internal objects, classes, functions, etc. These **are not a part of the Public API** and you **should not use them**, as we do not guarantee their backwards compatibility between different library versions. They are only documented here for linkable reference to them. .. autoclass:: mcstatus._protocol.java_client.JavaClient :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus._protocol.java_client.AsyncJavaClient :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus._protocol.legacy_client.LegacyClient :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus._protocol.legacy_client.AsyncLegacyClient :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus._protocol.bedrock_client.BedrockClient :members: :undoc-members: :show-inheritance: .. automodule:: mcstatus._net.address :members: :exclude-members: Address :undoc-members: :show-inheritance: .. autoclass:: Address :members: :undoc-members: :show-inheritance: .. attribute:: host :type: str :canonical: mcstatus._net.address.Address.host The hostname or IP address of the server. .. attribute:: port :type: int :canonical: mcstatus._net.address.Address.port The port of the server. .. automodule:: mcstatus._net.dns :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.responses.base.BaseStatusResponse :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.responses.base.BaseStatusPlayers :members: :undoc-members: :show-inheritance: .. autoclass:: mcstatus.responses.base.BaseStatusVersion :members: :undoc-members: :show-inheritance: py-mine-mcstatus-341e7a4/docs/api/motd_parsing.rst000066400000000000000000000011131524603215100221770ustar00rootroot00000000000000MOTD Parsing ============ We provide a really powerful system to parse servers MOTDs. The main class -------------- Firstly there is the main class, which you get directly from :meth:`status ` methods. .. autoclass:: mcstatus.motd.Motd :members: :undoc-members: Components ---------- Those are used in :attr:`~mcstatus.motd.Motd.parsed` field. .. automodule:: mcstatus.motd.components :members: :undoc-members: .. py:type:: ParsedMotdComponent :canonical: Formatting | MinecraftColor | WebColor | TranslationTag | str py-mine-mcstatus-341e7a4/docs/conf.py000066400000000000000000000116321524603215100175140ustar00rootroot00000000000000"""Configuration file for the Sphinx documentation builder. This file does only contain a selection of the most common options. For a full list see the documentation: http://www.sphinx-doc.org/en/master/config """ # -- Path setup -------------------------------------------------------------- # 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 # documentation root, use os.path.abspath to make it absolute, like shown here. from __future__ import annotations import os import sys from importlib.metadata import version as importlib_version from typing import TYPE_CHECKING from packaging.version import Version, parse as parse_version if TYPE_CHECKING: from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] sys.path.insert(0, os.path.abspath("..")) # ruff: ignore[os-path-abspath] # -- Project information ----------------------------------------------------- def _get_version() -> Version: return parse_version(importlib_version("mcstatus")) project = "mcstatus" copyright = "mcstatus, py-mine" author = "Dinnerbone" parsed_version = _get_version() # The short X.Y version version = parsed_version.base_version # The full version, including alpha/beta/rc tags release = str(parsed_version) # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named "sphinx.ext.*") or your custom # ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.viewcode", "sphinx.ext.autosummary", "sphinx.ext.autosectionlabel", # Used to reference for third party projects: "sphinx.ext.intersphinx", # Used to include .md files: "m2r2", ] autoclass_content = "both" autodoc_member_order = "bysource" autodoc_default_flags = { "members": "", "undoc-members": "code,error_template", "exclude-members": "__dict__,__weakref__", } # Automatically generate section labels: autosectionlabel_prefix_document = True # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: source_suffix = [".rst", ".md"] # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" add_module_names = False # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. 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 # documentation. html_theme_options = { "navigation_with_keys": True, } # -- Extension configuration ------------------------------------------------- # Third-party projects documentation references: intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "dns": ("https://dnspython.readthedocs.io/en/stable/", None), } # -- Options for todo extension ---------------------------------------------- # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # Mocks def mock_autodoc() -> None: """Mock autodoc to not add ``Bases: object`` to the classes, that do not have super classes. See also https://stackoverflow.com/a/75041544/20952782. """ from sphinx.ext import autodoc # ruff: ignore[import-outside-top-level] class MockedClassDocumenter(autodoc.ClassDocumenter): @override def add_line(self, line: str, source: str, *lineno: int) -> None: if line == " Bases: :py:class:`object`": return super().add_line(line, source, *lineno) autodoc.ClassDocumenter = MockedClassDocumenter def delete_doc_for_address_base() -> None: """``__new__`` is appended to the docstring of ``AddressBase``. And we do not want autogenerated nonsense docstring there. """ # ruff: ignore[non-imperative-mood] from mcstatus._net.address import ( # ruff: ignore[import-outside-top-level, import-private-name] _AddressBase, # pyright: ignore[reportPrivateUsage] ) del _AddressBase.__new__.__doc__ mock_autodoc() delete_doc_for_address_base() py-mine-mcstatus-341e7a4/docs/examples/000077500000000000000000000000001524603215100200305ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/docs/examples/code/000077500000000000000000000000001524603215100207425ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/docs/examples/code/ping_as_java_and_bedrock_in_one_time.py000066400000000000000000000034151524603215100306200ustar00rootroot00000000000000from __future__ import annotations import asyncio from mcstatus import BedrockServer, JavaServer from mcstatus.responses.bedrock import BedrockStatusResponse from mcstatus.responses.java import JavaStatusResponse async def handle_java(host: str) -> JavaStatusResponse: """Wrap mcstatus, to compress lookup and status into one function.""" server = await JavaServer.async_lookup(host) return await server.async_status() async def handle_bedrock(host: str) -> BedrockStatusResponse: """Wrap mcstatus, to compress lookup and status into one function.""" # note: `BedrockServer` doesn't have `async_lookup` method, see it's docstring server = BedrockServer.lookup(host) return await server.async_status() async def status(host: str) -> JavaStatusResponse | BedrockStatusResponse: """Get status from a server which can be either Java or Bedrock. The function will ping the server as both Java and as Bedrock at once, and return the first valid response, or fail. """ tasks: list[asyncio.Task[JavaStatusResponse | BedrockStatusResponse]] = [ asyncio.create_task(handle_java(host)), asyncio.create_task(handle_bedrock(host)), ] try: for task in asyncio.as_completed(tasks): try: result = await task except Exception: # ruff: ignore[try-except-continue, try-except-in-loop] continue else: # Cancel the remaining task, we succeeded for t in tasks: if t is not task: t.cancel() return result finally: # Ensure all tasks are cleaned up for t in tasks: t.cancel() raise ValueError("No tasks were successful. Is the server offline?") py-mine-mcstatus-341e7a4/docs/examples/code/ping_many_servers_at_once.py000066400000000000000000000015111524603215100265340ustar00rootroot00000000000000import asyncio from mcstatus import JavaServer async def ping_server(ip: str) -> None: try: status = await (await JavaServer.async_lookup(ip)).async_status() except Exception: return print(f"{ip} - {status.latency}ms") # handle somehow responses here async def ping_ips(ips: list[str]) -> None: to_process: list[str] = [] for ip in ips: if len(to_process) <= 10: # 10 means here how many servers will be pinged at once to_process.append(ip) continue await asyncio.wait({asyncio.create_task(ping_server(ip_to_ping)) for ip_to_ping in to_process}) to_process = [] def main() -> None: ips = ["hypixel.net", "play.hivemc.com", "play.cubecraft.net"] # insert your ips here! asyncio.run(ping_ips(ips)) if __name__ == "__main__": main() py-mine-mcstatus-341e7a4/docs/examples/code/player_list_from_query_with_fallback_on_status.py000066400000000000000000000006761524603215100330750ustar00rootroot00000000000000from mcstatus import JavaServer server = JavaServer.lookup("play.hypixel.net") query = server.query() if query.players.list: print("Players online:", ", ".join(query.players.list)) else: status = server.status() if not status.players.sample: print("Cant find players list, no one online or the server disabled this.") else: print("Players online:", ", ".join([player.name for player in status.players.sample])) py-mine-mcstatus-341e7a4/docs/examples/examples.rst000066400000000000000000000004731524603215100224040ustar00rootroot00000000000000Examples ======== We have these examples at the moment: .. toctree:: :maxdepth: 1 :caption: Examples ping_as_java_and_bedrock_in_one_time.rst ping_many_servers_at_once.rst player_list_from_query_with_fallback_on_status.rst Feel free to propose us more examples, we will be happy to add them to the list! py-mine-mcstatus-341e7a4/docs/examples/ping_as_java_and_bedrock_in_one_time.rst000066400000000000000000000015131524603215100300630ustar00rootroot00000000000000Ping as Java and as Bedrock in one time ======================================= You can easily ping a server as a Java server and as a Bedrock server in one time. .. literalinclude:: code/ping_as_java_and_bedrock_in_one_time.py As you can see in the code, ``status`` function returns :class:`~mcstatus.responses.java.JavaStatusResponse` or :class:`~mcstatus.responses.bedrock.BedrockStatusResponse` object. You can use :func:`isinstance` checks to access attributes that are only in one of the objects. .. code-block:: python response = await status("hypixel.net") if isinstance(response, BedrockStatusResponse): map_name = response.map_name else: map_name = None # or map_name = response.map_name if isinstance(response, BedrockStatusResponse) else None print(f"Server map name is: {map_name}") py-mine-mcstatus-341e7a4/docs/examples/ping_many_servers_at_once.rst000066400000000000000000000002701524603215100260030ustar00rootroot00000000000000Ping many servers at once ========================= You can ping many servers at once with mcstatus async methods, just look at .. literalinclude:: code/ping_many_servers_at_once.py py-mine-mcstatus-341e7a4/docs/examples/player_list_from_query_with_fallback_on_status.rst000066400000000000000000000002761524603215100323370ustar00rootroot00000000000000Get player list from query, while falling back on status ======================================================== .. literalinclude:: code/player_list_from_query_with_fallback_on_status.py py-mine-mcstatus-341e7a4/docs/index.rst000066400000000000000000000005751524603215100200620ustar00rootroot00000000000000.. mdinclude:: ../README.md Content ------- .. toctree:: :maxdepth: 1 :caption: Pages pages/faq.rst pages/contributing.rst pages/versioning.rst examples/examples.rst .. toctree:: :maxdepth: 1 :caption: API Documentation api/basic.rst api/motd_parsing.rst api/internal.rst Indices and tables ------------------ * :ref:`genindex` * :ref:`modindex` * :ref:`search` py-mine-mcstatus-341e7a4/docs/pages/000077500000000000000000000000001524603215100173115ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/docs/pages/contributing.rst000066400000000000000000000046221524603215100225560ustar00rootroot00000000000000Contributing ============ Setup ----- .. code-block:: sh pipx install uv uv sync # The following command will depend on your operating system and shell. # For MacOS/Linux: . .venv/bin/activate # For Windows CMD: .venv\Scripts\activate.bat # For Windows PowerShell: .venv\Scripts\Activate.ps1 pre-commit install In addition to this, you may also want to install ruff and basedpyright plugins for your IDE. Expectations ------------ When making changes to code that results in new behavior, it is expected that automated tests are submitted as well to prevent the behavior from breaking in the future. It matters not whether those changes are for a bugfix or a new feature; all behavior changes require tests. Feel free to hop on the `Discord server `_ to chat with other maintainers if you are unsure about something. We would definitely rather have a conversation first before consuming unnecessary time for something that someone else could already be working on or any other reason that might make the work unnecessary. It is a community effort to maintain and grow mcstatus. Much discussion happens on the `Discord server `_ to collaborate ideas together. Once you have all the checks passing and any new behavior changes are tested, feel free to open a pull request. Pull requests are how GitHub allows forks to submit branches for consideration to be merged into the original repo. Common development tasks ------------------------ .. code-block:: sh # Activating the virtual environment, allowing you to work with the project's dependencies # installed there by uv. This command is OS and shell dependent. # For MacOS/Linux: . .venv/bin/activate # For Windows CMD: .venv\Scripts\activate.bat # For Windows PowerShell: .venv\Scripts\Activate.ps1 poe docs # Renders documentation from docs/ folder poe format # Executes automatic formatter for style consistency poe lint # Executes linting tools that help increase code quality poe test # Executes unit tests Listing available tasks ----------------------- .. code-block:: sh poe Being fancy with tasks ---------------------- You may pass extra arguments to the underlying tasks. Here's an example that tells the underlying ``pytest`` to execute only the query tests with maximum verbosity. .. code-block:: sh poe test -vvv -k TestQuery py-mine-mcstatus-341e7a4/docs/pages/faq.rst000066400000000000000000000065331524603215100206210ustar00rootroot00000000000000Frequently Asked Questions ========================== Why doesn't :class:`~mcstatus.server.BedrockServer` have an async :meth:`~mcstatus.server.MCServer.lookup` method? ------------------------------------------------------------------------------------------------------------------ With Java servers, to find the server, we sometimes end up performing an SRV DNS lookup. This means making a request to your DNS server and waiting for an answer, making that lookup a blocking operation (during which other things can be done). .. note:: An SRV record allows the server to have an address like: ``hypixel.net``, that points to a some specified IP/Host and port, depending on this record. That way, even if the server is hosted on a non-standard port (other than 25565, say 8855), you won't need to use ``myserver.com:8855``, since the port number will simply be stored in the SRV record, so people can still connect simply with ``myserver.com``. On top of that, it also allows to specify a different IP/Host, which means you don't need to use the same server to run both the website, and the minecraft server. Instead, the SRV record can simply point to a different IP/Host address (like ``mc.hypixel.net``, or ``209.222.114.112``). However with Bedrock servers, no such lookups are required (Bedrock doesn't support SRV records), and so there is no blocking I/O operation being made, that would justify having an async version. In fact, all that the bedrock lookup does is parsing the ``host:port`` address, and obtaining the ``host`` and ``port`` parts out of it (with some error handling, and support for default ports). Incorrect encoding ------------------ In Query protocol, Minecraft uses ISO 8859-1 for encoding all text (like MOTD, server name, etc.). This can cause problems with non-latin characters. To fix such error, you can re-encode text into UTF-8. .. code-block:: python >>> query = JavaServer.lookup("my-server-ip.com").query() >>> query.motd.to_minecraft() 'Ð\x9fÑ\x80ивÑ\x96Ñ\x82!' >>> query.motd.to_minecraft().encode("iso-8859-1").decode("utf-8") 'Привіт!' :attr:`query.motd ` here can be anything, that contains incorrect encoding. How to get server image? ------------------------ On Bedrock, only official servers have a server image. There is no way to get or set an icon to a custom server. For Java servers, you can use :attr:`status.icon ` attribute. It will return `Base64 `_ encoded PNG image. If you wish to save this image into a file, this is how: .. code-block:: python import base64 from mcstatus import JavaServer server = JavaServer.lookup("hypixel.net") status = server.status() decoded_icon = base64.b64decode(status.icon.removeprefix("data:image/png;base64,")) with open("server-icon.png", "wb") as f: f.write(decoded_icon) .. note:: Most modern browsers support simply pasting the raw Base64 image into the URL bar, which will open it as an image preview, allowing you to take a quick look at it without having to use file saving from Python. See `How to display Base64 image `_ and `Base64 Images: Support table `_. py-mine-mcstatus-341e7a4/docs/pages/versioning.rst000066400000000000000000000261231524603215100222320ustar00rootroot00000000000000Versioning Practices & Guarantees ================================= This page explains what you can expect when upgrading mcstatus, and what changes may occur in major, minor, and patch releases. mcstatus follows the `Semantic Versioning `_ model in terms of **versioning guarantees and expectations**, using the familiar ``MAJOR.MINOR.PATCH`` structure. Internally and for distribution, mcstatus version numbers follow `PEP 440 `_. This primarily affects the exact format of pre-releases, post-releases, and development releases, but does not change the meaning of major, minor, or patch version increments. - **MAJOR**: incompatible (breaking) changes to the public API - **MINOR**: backwards-compatible features and improvements - **PATCH**: backwards-compatible bug fixes What is "public API"? --------------------- For mcstatus, the **public API** is defined by what is **documented in the public API pages**. - Anything documented under :doc:`/api/basic` and :doc:`/api/motd_parsing` is public API. - Anything documented under :doc:`/api/internal` is **not** public API. - Anything not documented at all is also **not** public API. - Any module, package, attribute, or symbol whose name starts with an underscore (``_``) is considered internal and may change at any time. Release types and guarantees ---------------------------- .. warning:: **Bug fixes are generally not backported.** mcstatus primarily supports the **latest released version** of the library. Bugs are fixed only in that version, and fixes are not backported to older releases. This includes most bug fixes and the vast majority of security-related fixes. Backporting fixes significantly increases maintenance overhead and often requires maintaining multiple diverging code paths, which is not sustainable for this project. In exceptional cases, a truly critical issue may be addressed via a hotfix release. Such cases are rare and handled on a best-effort basis. If you rely on older versions of mcstatus, you may encounter bugs that will not be fixed unless you upgrade. Patch releases (x.y.PATCH) ~~~~~~~~~~~~~~~~~~~~~~~~~~ Patch releases contain: - bug fixes - documentation changes of any kind (e.g. new docs pages, or also changes to docstrings of some public API objects in the codebase) - improvements that do not change the public API contract - breaking changes in private API - dependency updates (including major dependency updates), as long as the public contract of this library remains compatible - changes in library's public API typing behavior (see :ref:`typing-stability-guarantees`) Patch releases do **not** contain breaking changes to the public API. They also generally don't introduce any new behavior, other than for purposes of resolving existing bugs, or internal updates. Note that in some cases, if you are relying on behavior which we consider to be a bug, it is possible that we might end up changing this behavior in a `PATCH` release, in an effort to fix the unintentional, wrong behavior, breaking your dependency. Bug-fixes pretty much always happen without any deprecations. .. admonition:: Example To understand what constitutes a bug-fix with such breakage potential, as an example, if mcstatus incorrectly parses a MOTD format that some server sends, fixing that parsing is a bug fix, even if it changes the output format for that specific broken MOTD. Another important note to mention is that mcstatus will not make any efforts to delay its runtime dependency version updates to align them with minor or major releases. Transitive breakages caused by dependency updates are considered acceptable as long as mcstatus's documented public API remains compatible. Minor releases (x.MINOR.z) ~~~~~~~~~~~~~~~~~~~~~~~~~~ Generally, minor releases exist to introduce new features to the library in a non-breaking way. They may include: - new public features (new classes, functions, parameters, constants) without affecting existing behavior - new optional arguments in public functions/class constructors with sensible defaults that don't change existing usage - **new deprecations** (introduced as warnings containing new replacements / deprecation reasons) - backwards-compatible behavior improvements - dropping support for a Python version (e.g. dropping Python 3.9 because it is past its `end-of-life `_) - any additional changes that patch releases can contain Minor releases do **not** intentionally introduce breaking changes to the documented public API. Deprecations in minor releases ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ New deprecations may be introduced in minor releases for behavior that is scheduled for removal in the next major version. These deprecations are emitted as warnings and do not immediately break existing code. For more information about our deprecation handling, see the :ref:`deprecations-and-removals` section. Major releases (MAJOR.y.z) ~~~~~~~~~~~~~~~~~~~~~~~~~~ Major releases may include breaking changes to the documented public API. These will be called out in the changelog and typically include one or more of: - removing a documented public class, function, or attribute - renaming a public API without a deprecated alias - changing the default value or meaning of a function parameter in a way that changes original usage - removing a deprecated alias or deprecated import path - any additional changes that minor and patch releases can contain We generally try to avoid immediate breaking changes that didn't go through at least a brief deprecation period of at least 1 release cycle (e.g. deprecation will first be introduced in v12.4.0, before the old behavior is removed in v13). If a major refactor of the library was performed, or just generally one that is expected to make a lot of our users to face deprecations, we try to introduce these deprecations in a major release, instead of a minor one (with them staying in the project until the next major release after this one). Though this is not a guarantee. We can still choose to not go through deprecations at all and introduce entirely new breaking changes in a new major release. We will however try to avoid doing so unless we have a very strong reason to do so. mcstatus and Minecraft versions ------------------------------- mcstatus is somewhat coupled to Minecraft server protocols and behaviors. As Minecraft evolves, mcstatus may need to make changes that are "breaking" at the library level, even if they are driven by protocol or ecosystem changes. mcstatus aims to: - remain compatible with widely used Minecraft versions and server implementations - release updates in a timely manner when protocol behavior or common server responses change - provide support for legacy Minecraft versions (within reason), if the latest protocol for obtaining status is no longer compatible with the previous one Fortunately, breaking changes in the protocol when it comes to obtaining server status are very uncommon. But it is possible that Minecraft introduces a change that our library cannot process at the time of introduction, this might or might not cause hard failures on mcstatus part, even if older Minecraft clients can process these information, mcstatus might not be able to, until we release a new version to support it. Because mcstatus is maintained by volunteers, timing may vary, but we try to keep mcstatus working with the latest Minecraft releases and fix critical bugs quickly. .. _typing-stability-guarantees: Typing stability guarantees --------------------------- mcstatus is a strongly typed library which actively supports and encourages the use of type-checkers. However, typing definitions occasionally need to change alongside internal refactors so that mcstatus itself remains internally type-correct, and Python's typing system unfortunately does not really provide a practical way to deprecate types gracefully. For this reason, **typing breakages may occur even in patch releases**. We actively try to avoid typing breakages or postpone them to minor or even major releases when possible, but if doing so would significantly slow down our ability to deliver a necessary bug-fix or feature, we do not consider maintaining the stability of the public typing interface significant enough to prevent us from shipping such a change. .. admonition:: Example To understand what we meant by breaking changes in the public typing interface, it can include things like: - Adding a ``@final`` decorator to one of our classes - Making a class generic - Introducing an additional type parameter to already generic class - Removing a generic type parameter / making a class no longer generic - Adding a convenience type-alias variable - Adding a new type into a union of types in an exposed convenience type-alias Any of these changes can occur even in a patch release. .. _deprecations-and-removals: Deprecations and removals ------------------------- When we deprecate something, we aim to emit a ``DeprecationWarning`` with a message containing: - what was deprecated - what to use instead (if a replacement is available) - a target removal version When we deprecate something, we generally aim to remove it in a **major release**, after it has been deprecated for at least one release cycle. (E.g. a deprecation introduced in ``v12.2.0`` will most likely have its removal scheduled in ``v13.0.0`` ). For some more significant changes, we can sometimes keep a deprecation around for longer though (e.g. a deprecation introduced in ``v12.2.0`` with removal scheduled for ``v14.0.0``). We will **always** explicitly include the removal version, until which the deprecated behavior will still be guaranteed to remain functional. Post-removal deprecation handling ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Once a deprecated feature has passed its stated removal version, its use will result in a breaking change. This is guaranteed to happen in the stated removal version. .. warning:: **Relying on deprecated behavior after its removal is unsupported.** Where feasible, mcstatus will explicitly raise the corresponding ``DeprecationWarning`` as a hard exception, rather than allowing the removal to manifest as a less clear runtime failures (such as ``AttributeError``). This is a deliberate best-effort attempt to provide clearer diagnostics and improve the upgrade experience. This behavior is **not part of the versioning guarantees**. Any post-removal deprecation handling is considered **temporary by design** and may be intentionally removed after some time, including in patch releases, once the breakage has been in effect for a reasonable period (typically 1-5 months). Users must not rely on the presence, wording, or longevity of post-removal deprecation handling. After removal, failures may surface as generic runtime errors without any direct reference to the original deprecation. For this reason, you should always pay attention to deprecation warnings and resolve them ahead of time, ideally after any minor updates, but at the very least before upgrading to a new major version, to avoid unclear hard breakages. py-mine-mcstatus-341e7a4/docs/pyproject.toml000066400000000000000000000006131524603215100211260ustar00rootroot00000000000000[project] name = "docs" version = "0.0.0" license = "Apache-2.0" requires-python = ">=3.12" dependencies = [ "sphinx~=9.1.0", "sphinx-autodoc-typehints~=3.13.0", "furo>=2025.7.19", "m2r2~=0.3.4", "packaging~=26.0", "uv-dynamic-versioning~=0.14.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] bypass-selection = true py-mine-mcstatus-341e7a4/mcstatus/000077500000000000000000000000001524603215100171255ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/__init__.py000066400000000000000000000002501524603215100212330ustar00rootroot00000000000000from mcstatus.server import BedrockServer, JavaServer, LegacyServer, MCServer __all__ = [ "BedrockServer", "JavaServer", "LegacyServer", "MCServer", ] py-mine-mcstatus-341e7a4/mcstatus/__main__.py000066400000000000000000000147421524603215100212270ustar00rootroot00000000000000# ruff: file-ignore[print] from __future__ import annotations import argparse import json import socket import sys from typing import Any, TYPE_CHECKING, TypeAlias import dns.resolver from mcstatus import BedrockServer, JavaServer, LegacyServer from mcstatus.responses import JavaStatusResponse if TYPE_CHECKING: from mcstatus.motd import Motd SupportedServers: TypeAlias = JavaServer | LegacyServer | BedrockServer PING_PACKET_FAIL_WARNING = ( "warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n" " this is likely a bug in the server-side implementation.\n" ' (note: ping packet failed due to "{ping_exc}")\n' " for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n" ) QUERY_FAIL_WARNING = ( "The server did not respond to the query protocol." "\nPlease ensure that the server has enable-query turned on," " and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)." "\nSee https://minecraft.wiki/w/Query for further information." ) def _motd(motd: Motd) -> str: """Format MOTD for human-readable output, with leading line break if multiline.""" s = motd.to_ansi() return f"\n{s}" if "\n" in s else f" {s}" def _kind(serv: SupportedServers) -> str: if isinstance(serv, JavaServer): return "Java" if isinstance(serv, LegacyServer): return "Java (pre-1.7)" if isinstance(serv, BedrockServer): # pyright: ignore[reportUnnecessaryIsInstance] # be explicit return "Bedrock" raise ValueError(f"unsupported server for kind: {serv}") def _ping_with_fallback(server: SupportedServers) -> float: # only Java has ping method if not isinstance(server, JavaServer): return server.status().latency # try faster ping packet first, falling back to status with a warning. try: return server.ping(tries=1) except Exception as e: # ruff: ignore[blind-except] ping_exc = e latency = server.status().latency address = f"{server.address.host}:{server.address.port}" print( PING_PACKET_FAIL_WARNING.format(address=address, ping_exc=ping_exc), file=sys.stderr, ) return latency def ping_cmd(server: SupportedServers) -> int: print(_ping_with_fallback(server)) return 0 def status_cmd(server: SupportedServers) -> int: response = server.status() java_res = response if isinstance(response, JavaStatusResponse) else None if java_res and java_res.players.sample: player_sample = "\n " + "\n ".join(f"{player.name} ({player.id})" for player in java_res.players.sample) else: player_sample = "" print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})") print(f"motd:{_motd(response.motd)}") print(f"players: {response.players.online}/{response.players.max}{player_sample}") print(f"ping: {response.latency:.2f} ms") return 0 def json_cmd(server: SupportedServers) -> int: data: dict[str, Any] = {"online": False, "kind": _kind(server)} status_res = query_res = exn = None try: status_res = server.status(tries=1) except Exception as e: # ruff: ignore[blind-except] exn = exn or e try: if isinstance(server, JavaServer): query_res = server.query(tries=1) except Exception as e: # ruff: ignore[blind-except] exn = exn or e # construct 'data' dict outside try/except to ensure data processing errors # are noticed. data["online"] = bool(status_res or query_res) if not data["online"]: assert exn, "server offline but no exception?" data["error"] = str(exn) if status_res is not None: data["status"] = status_res.as_dict() if query_res is not None: data["query"] = query_res.as_dict() json.dump(data, sys.stdout) return 0 def query_cmd(server: SupportedServers) -> int: if not isinstance(server, JavaServer): print("The 'query' protocol is only supported by Java servers.", file=sys.stderr) return 1 try: response = server.query() except TimeoutError: print(QUERY_FAIL_WARNING, file=sys.stderr) return 1 print(f"host: {response.raw['hostip']}:{response.raw['hostport']}") print(f"software: {_kind(server)} {response.software.version} {response.software.brand}") print(f"motd:{_motd(response.motd)}") print(f"plugins: {response.software.plugins}") print(f"players: {response.players.online}/{response.players.max} {response.players.list}") return 0 def main(argv: list[str] = sys.argv[1:]) -> int: parser = argparse.ArgumentParser( "mcstatus", description=""" mcstatus provides an easy way to query Minecraft servers for any information they can expose. It provides three modes of access: query, status, ping and json. """, ) _ = parser.add_argument("address", help="The address of the server.") group = parser.add_mutually_exclusive_group() _ = group.add_argument( "--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true" ) _ = group.add_argument( "--legacy", help="Specifies that 'address' is a pre-1.7 Java server (default: 1.7+).", action="store_true" ) subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.") parser.set_defaults(func=status_cmd) subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd) subparsers.add_parser("status", help="Prints server status.").set_defaults(func=status_cmd) subparsers.add_parser( "query", help="Prints detailed server information. Must be enabled in servers' server.properties file." ).set_defaults(func=query_cmd) subparsers.add_parser( "json", help="Prints server status and query in json.", ).set_defaults(func=json_cmd) args = parser.parse_args(argv) if args.bedrock: lookup = BedrockServer.lookup elif args.legacy: lookup = LegacyServer.lookup else: lookup = JavaServer.lookup try: server = lookup(args.address) return args.func(server) except (socket.gaierror, dns.resolver.NoNameservers, ConnectionError, TimeoutError) as e: # catch and hide traceback for expected user-facing errors print(f"Error: {e!r}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main()) py-mine-mcstatus-341e7a4/mcstatus/_compat/000077500000000000000000000000001524603215100205475ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_compat/README.md000066400000000000000000000023271524603215100220320ustar00rootroot00000000000000# Compatibility Shims This directory holds compatibility shims for deprecated public modules. These modules are not part of the main source tree. They are mapped into their deprecated import paths during packaging via Hatchling `force-include` entries in `pyproject.toml` for the sdist (wheels are built from the sdist). Example: ```toml [tool.hatch.build.targets.sdist.force-include] "mcstatus/_compat/status_response.py" = "mcstatus/status_response.py" ``` This means that the build system will include these for us in the actual built packages, without the files having to clutter the actual source tree of the project, making development cleaner and less confusing. As an additional benefit, it prevents us from accidentally importing these deprecated utils from within mcstatus, as they will simply not be present. > [!WARNING] > This approach does mean that people using mcstatus directly through a git > submodule or otherwise attempt to run the mcstatus code from it's direct > source, rather than going through the proper python module installation will > NOT be able to utilize these deprecations. > > This isn't really a supported method of utilizing mcstatus though, and people > that do so should expect to face issues. py-mine-mcstatus-341e7a4/mcstatus/_compat/__init__.py000066400000000000000000000000001524603215100226460ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_compat/forge_data.py000066400000000000000000000005131524603215100232130ustar00rootroot00000000000000from mcstatus._utils import deprecation_warn from mcstatus.responses.forge import ForgeData, ForgeDataChannel, ForgeDataMod __all__ = [ "ForgeData", "ForgeDataChannel", "ForgeDataMod", ] deprecation_warn( obj_name="mcstatus.forge_data", removal_version="14.0.0", replacement="mcstatus.responses.forge", ) py-mine-mcstatus-341e7a4/mcstatus/_net/000077500000000000000000000000001524603215100200525ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_net/__init__.py000066400000000000000000000000001524603215100221510ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_net/address.py000066400000000000000000000244631524603215100220620ustar00rootroot00000000000000from __future__ import annotations import ipaddress import sys import warnings from typing import NamedTuple, TYPE_CHECKING from urllib.parse import urlparse import dns.resolver from mcstatus._net import dns as mc_dns if TYPE_CHECKING: from pathlib import Path from typing_extensions import Self __all__ = [ "Address", "async_minecraft_srv_address_lookup", "minecraft_srv_address_lookup", ] def _valid_urlparse(address: str) -> tuple[str, int | None]: """Parse a string address like 127.0.0.1:25565 into host and port parts. If the address doesn't have a specified port, None will be returned instead. :raises ValueError: Unable to resolve hostname of given address """ tmp = urlparse("//" + address) if not tmp.hostname: raise ValueError(f"Invalid address {address!r}, can't parse.") return tmp.hostname, tmp.port class _AddressBase(NamedTuple): """Intermediate NamedTuple class representing an address. We can't extend this class directly, since NamedTuples are slotted and read-only, however child classes can extend __new__, allowing us do some needed processing on child classes derived from this base class. """ host: str port: int class Address(_AddressBase): """Extension of a :class:`~typing.NamedTuple` of :attr:`.host` and :attr:`.port`, for storing addresses. This class inherits from :class:`tuple`, and is fully compatible with all functions which require pure ``(host, port)`` address tuples, but on top of that, it includes some neat functionalities, such as validity ensuring, alternative constructors for easy quick creation and methods handling IP resolving. .. note:: The class is not a part of a Public API, but attributes :attr:`host` and :attr:`port` are a part of Public API. """ def __init__(self, host: str, port: int) -> None: # ruff: ignore[unused-method-argument] # We don't pass the host & port args to super's __init__, because NamedTuples handle # everything from __new__ and the passed self already has all of the parameters set. super().__init__() self._cached_ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None # Make sure the address is valid self._ensure_validity(self.host, self.port) @staticmethod def _ensure_validity(host: object, port: object) -> None: if not isinstance(host, str): raise TypeError(f"Host must be a string address, got {type(host)} ({host!r})") if not isinstance(port, int): raise TypeError(f"Port must be an integer port number, got {type(port)} ({port!r})") if port > 65535 or port < 0: raise ValueError(f"Port must be within the allowed range (0-2^16), got {port!r}") @classmethod def from_tuple(cls, tup: tuple[str, int]) -> Self: """Construct the class from a regular tuple of ``(host, port)``, commonly used for addresses.""" return cls(host=tup[0], port=tup[1]) @classmethod def from_path(cls, path: Path, *, default_port: int | None = None) -> Self: """Construct the class from a :class:`~pathlib.Path` object. If path has a port specified, use it, if not fall back to ``default_port`` kwarg. In case ``default_port`` isn't provided and port wasn't specified, raise :exc:`ValueError`. """ address = str(path) return cls.parse_address(address, default_port=default_port) @classmethod def parse_address(cls, address: str, *, default_port: int | None = None) -> Self: """Parse a string address like ``127.0.0.1:25565`` into :attr:`.host` and :attr:`.port` parts. If the address has a port specified, use it, if not, fall back to ``default_port`` kwarg. :raises ValueError: Either the address isn't valid and can't be parsed, or it lacks a port and ``default_port`` wasn't specified. """ hostname, port = _valid_urlparse(address) if port is None: if default_port is not None: port = default_port else: raise ValueError( f"Given address {address!r} doesn't contain port and default_port wasn't specified, can't parse." ) return cls(host=hostname, port=port) def resolve_ip(self, lifetime: float | None = None) -> ipaddress.IPv4Address | ipaddress.IPv6Address: """Resolve a hostname's A record into an IP address. If the host is already an IP, this resolving is skipped and host is returned directly. :param lifetime: How many seconds a query should run before timing out. Default value for this is inherited from :func:`dns.resolver.resolve`. :raises dns.exception.DNSException: One of the exceptions possibly raised by :func:`dns.resolver.resolve`. Most notably this will be :exc:`dns.exception.Timeout` and :exc:`dns.resolver.NXDOMAIN` """ if self._cached_ip is not None: return self._cached_ip host = self.host if self.host == "localhost" and sys.platform == "darwin": host = "127.0.0.1" warnings.warn( "On macOS because of some mysterious reasons we can't resolve localhost into IP. " "Please, replace 'localhost' with '127.0.0.1' (or '::1' for IPv6) in your code to remove this warning.", category=RuntimeWarning, stacklevel=2, ) try: ip = ipaddress.ip_address(host) except ValueError: # ValueError is raised if the given address wasn't valid # this means it's a hostname and we should try to resolve # the A record ip_addr = mc_dns.resolve_a_record(self.host, lifetime=lifetime) ip = ipaddress.ip_address(ip_addr) self._cached_ip = ip return self._cached_ip async def async_resolve_ip(self, lifetime: float | None = None) -> ipaddress.IPv4Address | ipaddress.IPv6Address: """Resolve a hostname's A record into an IP address. See the docstring for :meth:`.resolve_ip` for further info. This function is purely an async alternative to it. """ if self._cached_ip is not None: return self._cached_ip host = self.host if self.host == "localhost" and sys.platform == "darwin": host = "127.0.0.1" warnings.warn( "On macOS because of some mysterious reasons we can't resolve localhost into IP. " "Please, replace 'localhost' with '127.0.0.1' (or '::1' for IPv6) in your code to remove this warning.", category=RuntimeWarning, stacklevel=2, ) try: ip = ipaddress.ip_address(host) except ValueError: # ValueError is raised if the given address wasn't valid # this means it's a hostname and we should try to resolve # the A record ip_addr = await mc_dns.async_resolve_a_record(self.host, lifetime=lifetime) ip = ipaddress.ip_address(ip_addr) self._cached_ip = ip return self._cached_ip def minecraft_srv_address_lookup( address: str, *, default_port: int | None = None, lifetime: float | None = None, ) -> Address: """Lookup the SRV record for a Minecraft server. Firstly it parses the address, if it doesn't include port, tries SRV record, and if it's not there, falls back on ``default_port``. This function essentially mimics the address field of a Minecraft Java server. It expects an address like ``192.168.0.100:25565``, if this address does contain a port, it will simply use it. If it doesn't, it will try to perform an SRV lookup, which if found, will contain the info on which port to use. If there's no SRV record, this will fall back to the given ``default_port``. :param address: The same address which would be used in minecraft's server address field. Can look like: ``127.0.0.1``, or ``192.168.0.100:12345``, or ``mc.hypixel.net``, or ``example.com:12345``. :param lifetime: How many seconds a query should run before timing out. Default value for this is inherited from :func:`dns.resolver.resolve`. :raises ValueError: Either the address isn't valid and can't be parsed, or it lacks a port, SRV record isn't present, and ``default_port`` wasn't specified. """ host, port = _valid_urlparse(address) # If we found a port in the address, there's nothing more we need if port is not None: return Address(host, port) # Otherwise, try to check for an SRV record, pointing us to the # port which we should use. If there's no such record, fall back # to the default_port (if it's defined). try: host, port = mc_dns.resolve_mc_srv(host, lifetime=lifetime) except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e: if default_port is None: raise ValueError( f"Given address {address!r} doesn't contain port, doesn't have an SRV record pointing to a port," " and default_port wasn't specified, can't parse." ) from e port = default_port return Address(host, port) async def async_minecraft_srv_address_lookup( address: str, *, default_port: int | None = None, lifetime: float | None = None, ) -> Address: """Just an async alternative to :func:`.minecraft_srv_address_lookup`, check it for more details.""" host, port = _valid_urlparse(address) # If we found a port in the address, there's nothing more we need if port is not None: return Address(host, port) # Otherwise, try to check for an SRV record, pointing us to the # port which we should use. If there's no such record, fall back # to the default_port (if it's defined). try: host, port = await mc_dns.async_resolve_mc_srv(host, lifetime=lifetime) except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer) as e: if default_port is None: raise ValueError( f"Given address {address!r} doesn't contain port, doesn't have an SRV record pointing to a port," " and default_port wasn't specified, can't parse." ) from e port = default_port return Address(host, port) py-mine-mcstatus-341e7a4/mcstatus/_net/dns.py000066400000000000000000000137071524603215100212200ustar00rootroot00000000000000from __future__ import annotations import asyncio from typing import TYPE_CHECKING, cast import dns.asyncresolver import dns.resolver from dns.rdatatype import RdataType if TYPE_CHECKING: from dns.rdtypes.IN.A import A as ARecordAnswer from dns.rdtypes.IN.AAAA import AAAA as AAAARecordAnswer # ruff: ignore[constant-imported-as-non-constant] from dns.rdtypes.IN.SRV import SRV as SRVRecordAnswer # ruff: ignore[constant-imported-as-non-constant] __all__ = [ "async_resolve_a_record", "async_resolve_mc_srv", "async_resolve_srv_record", "resolve_a_record", "resolve_mc_srv", "resolve_srv_record", ] def resolve_a_record(hostname: str, lifetime: float | None = None) -> str: """Perform a DNS resolution for an A/AAAA record to given hostname. :param hostname: The address to resolve for. :return: The resolved IP address from the A/AAAA record :raises dns.exception.DNSException: One of the exceptions possibly raised by :func:`dns.resolver.resolve`. Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN` and :exc:`dns.resolver.NoAnswer` The `NoAnswer` exception will only be raised if neither A nor AAAA responses exist. """ # Prioritize IPv4, if available try: answer = dns.resolver.resolve(hostname, RdataType.A, lifetime=lifetime, search=True) except dns.resolver.NoAnswer: answer = dns.resolver.resolve(hostname, RdataType.AAAA, lifetime=lifetime, search=True) # There should only be one record here, though in case the server # does actually point to multiple IPs, we just pick the first one record = cast("ARecordAnswer | AAAARecordAnswer", answer[0]) ip = str(record).rstrip(".") return ip async def async_resolve_a_record(hostname: str, lifetime: float | None = None) -> str: """Asynchronous alternative to :func:`.resolve_a_record`. For more details, check it. """ a_task = asyncio.create_task( dns.asyncresolver.resolve( hostname, RdataType.A, lifetime=lifetime, search=True, raise_on_no_answer=False, ) ) aaaa_task = asyncio.create_task( dns.asyncresolver.resolve( hostname, RdataType.AAAA, lifetime=lifetime, search=True, raise_on_no_answer=False, ) ) # prioritize IPv4 if available try: a_answer = await a_task except BaseException: # Cancel the AAAA task if still running, then consume its result. # Any exception from the unused lookup is intentionally discarded. _ = aaaa_task.cancel() _ = await asyncio.gather(aaaa_task, return_exceptions=True) raise if a_answer.rrset is not None: answer = a_answer # Cancel the AAAA task if still running, then consume its result. # Any exception from the unused lookup is intentionally discarded. _ = aaaa_task.cancel() _ = await asyncio.gather(aaaa_task, return_exceptions=True) else: aaaa_answer = await aaaa_task if aaaa_answer.rrset is None: # TODO: Consider ExceptionGroup return once we support >=3.11 raise dns.resolver.NoAnswer(response=aaaa_answer.response) answer = aaaa_answer for record in answer: record = cast("ARecordAnswer | AAAARecordAnswer", record) ip = str(record).rstrip(".") return ip raise RuntimeError(f"unreachable - DNS {answer=} was not a NoAnswer, but didn't have any records") def resolve_srv_record(query_name: str, lifetime: float | None = None) -> tuple[str, int]: """Perform a DNS resolution for SRV record pointing to the Java Server. :param query_name: The address to resolve for. :return: A tuple of host string and port number :raises dns.exception.DNSException: One of the exceptions possibly raised by :func:`dns.resolver.resolve`. Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN` and :exc:`dns.resolver.NoAnswer` """ answer = dns.resolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True) # There should only be one record here, though in case the server # does actually point to multiple IPs, we just pick the first one record = cast("SRVRecordAnswer", answer[0]) host = str(record.target).rstrip(".") port = int(record.port) return host, port async def async_resolve_srv_record(query_name: str, lifetime: float | None = None) -> tuple[str, int]: """Asynchronous alternative to :func:`.resolve_srv_record`. For more details, check it. """ answer = await dns.asyncresolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True) # There should only be one record here, though in case the server # does actually point to multiple IPs, we just pick the first one record = cast("SRVRecordAnswer", answer[0]) host = str(record.target).rstrip(".") port = int(record.port) return host, port def resolve_mc_srv(hostname: str, lifetime: float | None = None) -> tuple[str, int]: """Resolve SRV record for a minecraft server on given hostname. :param str hostname: The address, without port, on which an SRV record is present. :return: Obtained target and port from the SRV record, on which the server should live on. :raises dns.exception.DNSException: One of the exceptions possibly raised by :func:`dns.resolver.resolve`. Most notably this will be :exc:`dns.exception.Timeout`, :exc:`dns.resolver.NXDOMAIN` and :exc:`dns.resolver.NoAnswer`. """ return resolve_srv_record("_minecraft._tcp." + hostname, lifetime=lifetime) async def async_resolve_mc_srv(hostname: str, lifetime: float | None = None) -> tuple[str, int]: """Asynchronous alternative to :func:`.resolve_mc_srv`. For more details, check it. """ return await async_resolve_srv_record("_minecraft._tcp." + hostname, lifetime=lifetime) py-mine-mcstatus-341e7a4/mcstatus/_protocol/000077500000000000000000000000001524603215100211255ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_protocol/__init__.py000066400000000000000000000000001524603215100232240ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_protocol/bedrock_client.py000066400000000000000000000042561524603215100244550ustar00rootroot00000000000000from __future__ import annotations import asyncio import socket import struct from time import perf_counter from typing import ClassVar, TYPE_CHECKING, final import asyncio_dgram from mcstatus.responses import BedrockStatusResponse if TYPE_CHECKING: from mcstatus._net.address import Address __all__ = ["BedrockClient"] @final class BedrockClient: request_status_data: ClassVar[bytes] = bytes.fromhex( # see https://minecraft.wiki/w/RakNet#Unconnected_Ping "01" + "0000000000000000" + "00ffff00fefefefefdfdfdfd12345678" + "0000000000000000" ) def __init__(self, address: Address, timeout: float = 3) -> None: self.address = address self.timeout = timeout @staticmethod def parse_response(data: bytes, latency: float) -> BedrockStatusResponse: data = data[1:] name_length = struct.unpack(">H", data[32:34])[0] decoded_data = data[34 : 34 + name_length].decode().split(";") return BedrockStatusResponse.build(decoded_data, latency) def read_status(self) -> BedrockStatusResponse: start = perf_counter() data = self._read_status() end = perf_counter() return self.parse_response(data, (end - start) * 1000) def _read_status(self) -> bytes: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(self.timeout) _ = s.sendto(self.request_status_data, self.address) data, _ = s.recvfrom(2048) return data async def read_status_async(self) -> BedrockStatusResponse: start = perf_counter() data = await self._read_status_async() end = perf_counter() return self.parse_response(data, (end - start) * 1000) async def _read_status_async(self) -> bytes: stream = None try: conn = asyncio_dgram.connect(self.address) stream = await asyncio.wait_for(conn, timeout=self.timeout) await asyncio.wait_for(stream.send(self.request_status_data), timeout=self.timeout) data, _ = await asyncio.wait_for(stream.recv(), timeout=self.timeout) finally: if stream is not None: stream.close() return data py-mine-mcstatus-341e7a4/mcstatus/_protocol/io/000077500000000000000000000000001524603215100215345ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_protocol/io/__init__.py000066400000000000000000000000001524603215100236330ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_protocol/io/base_io.py000066400000000000000000000765401524603215100235230ustar00rootroot00000000000000from __future__ import annotations import math import struct from abc import ABC, abstractmethod from enum import Enum from typing import ClassVar, Literal, TYPE_CHECKING, TypeAlias, TypeVar, overload if TYPE_CHECKING: from collections.abc import Awaitable, Callable __all__ = [ "FLOAT_FORMATS_TYPE", "INT_FORMATS_TYPE", "BaseAsyncReader", "BaseAsyncWriter", "BaseSyncReader", "BaseSyncWriter", "StructFormat", ] T = TypeVar("T") R = TypeVar("R") # region: Utils def to_twos_complement(number: int, bits: int) -> int: """Convert a given ``number`` into two's complement format with the specified number of ``bits``. :param number: The signed integer to convert. :param bits: Number of bits used for the two's complement representation. :return: The integer encoded in two's complement form within the given bit width. :raises ValueError: If given ``number`` is out of range, and can't be converted into twos complement format, since it wouldn't fit into the given amount of ``bits``. """ value_max = 1 << (bits - 1) value_min = value_max * -1 # With two's complement, we have one more negative number than positive # this means we can't be exactly at value_max, but we can be at exactly value_min if number >= value_max or number < value_min: raise ValueError(f"Can't convert number {number} into {bits}-bit twos complement format - out of range") return number + (1 << bits) if number < 0 else number def from_twos_complement(number: int, bits: int) -> int: """Convert a ``number`` from a two's complement representation with the specified number of ``bits``. :param number: The integer encoded in two's complement form. :param bits: Number of bits used for the two's complement representation. :return: The decoded signed integer. :raises ValueError: If given ``number`` doesn't fit into given amount of ``bits``. This likely means that you're using the wrong number, or that the number was converted into twos complement with higher amount of `bits`. """ value_max = (1 << bits) - 1 if number < 0 or number > value_max: raise ValueError(f"Can't convert number {number} from {bits}-bit twos complement format - out of range") if number & (1 << (bits - 1)) != 0: number -= 1 << bits return number # endregion # region: Format types class StructFormat(str, Enum): """All possible struct format types used for reading and writing binary data. These values correspond directly to format characters accepted by the :mod:`struct` module. """ BOOL = "?" CHAR = "c" BYTE = "b" UBYTE = "B" SHORT = "h" USHORT = "H" INT = "i" UINT = "I" LONG = "l" ULONG = "L" FLOAT = "f" DOUBLE = "d" HALFFLOAT = "e" LONGLONG = "q" ULONGLONG = "Q" INT_FORMATS_TYPE: TypeAlias = Literal[ StructFormat.BYTE, StructFormat.UBYTE, StructFormat.SHORT, StructFormat.USHORT, StructFormat.INT, StructFormat.UINT, StructFormat.LONG, StructFormat.ULONG, StructFormat.LONGLONG, StructFormat.ULONGLONG, ] FLOAT_FORMATS_TYPE: TypeAlias = Literal[ StructFormat.FLOAT, StructFormat.DOUBLE, StructFormat.HALFFLOAT, ] # endregion # region: Writer classes class BaseAsyncWriter(ABC): """Base class holding asynchronous write buffer/connection interactions.""" __slots__: ClassVar[tuple[str, ...]] = () @abstractmethod async def write(self, data: bytes | bytearray, /) -> None: """Underlying write method, sending/storing the data. All of the writer functions will eventually call this method. """ @overload async def write_value(self, fmt: INT_FORMATS_TYPE, value: int, /) -> None: ... @overload async def write_value(self, fmt: FLOAT_FORMATS_TYPE, value: float, /) -> None: ... @overload async def write_value(self, fmt: Literal[StructFormat.BOOL], value: bool, /) -> None: ... # ruff: ignore[boolean-type-hint-positional-argument] @overload async def write_value(self, fmt: Literal[StructFormat.CHAR], value: bytes, /) -> None: ... async def write_value(self, fmt: StructFormat, value: object, /) -> None: """Write a given ``value`` as given struct format (``fmt``) in big-endian mode.""" await self.write(struct.pack(">" + fmt.value, value)) async def _write_varuint(self, value: int, /, *, max_bits: int | None = None) -> None: """Write an arbitrarily large unsigned integer using a variable-length encoding. This is a standard way of transmitting integers with variable length over the network, allowing smaller numbers take up fewer bytes. Writing is limited to integers representable within ``max_bits`` bits. Attempting to write a larger value will raise :class:`ValueError`. Note that limiting the value to e.g. 32 bits does not mean that at most 4 bytes will be written. Due to the encoding overhead, values within 32 bits may require up to 5 bytes. Varints encode integers using groups of 7 bits. The 7 least significant bits of each byte store data, while the most significant bit acts as a continuation flag. If this bit is set (``1``), another byte follows. The least significant group is written first, followed by increasingly significant groups, making the encoding little-endian in 7-bit groups. :param value: Unsigned integer to encode. :param max_bits: Maximum allowed bit width for the encoded value. :raises ValueError: If ``value`` is negative or exceeds the allowed bit width. """ value_max = (1 << (max_bits)) - 1 if max_bits is not None else float("inf") if value < 0 or value > value_max: raise ValueError(f"Tried to write varint outside of the range of {max_bits}-bit int.") remaining = value while True: if remaining & ~0x7F == 0: # final byte await self.write_value(StructFormat.UBYTE, remaining) return # Write only 7 least significant bits with the first bit being 1, marking there will be another byte await self.write_value(StructFormat.UBYTE, remaining & 0x7F | 0x80) # Subtract the value we've already sent (7 least significant bits) remaining >>= 7 async def write_varint(self, value: int, /) -> None: """Write a 32-bit signed integer using a variable-length encoding. The value is first converted to a 32-bit two's complement representation and then encoded using the varint format. See :meth:`_write_varuint` for details about the encoding. :param value: Signed integer to encode. """ val = to_twos_complement(value, bits=32) await self._write_varuint(val, max_bits=32) async def write_varlong(self, value: int, /) -> None: """Write a 64-bit signed integer using a variable-length encoding. The value is first converted to a 64-bit two's complement representation and then encoded using the varint format. See :meth:`_write_varuint` for details about the encoding. :param value: Signed integer to encode. """ val = to_twos_complement(value, bits=64) await self._write_varuint(val, max_bits=64) async def write_bytearray(self, data: bytes | bytearray, /) -> None: """Write an arbitrary sequence of bytes, prefixed with a varint of its size.""" await self.write_varint(len(data)) await self.write(data) async def write_ascii(self, value: str, /) -> None: """Write ISO-8859-1 encoded string, with NULL (0x00) at the end to indicate string end.""" data = bytes(value, "ISO-8859-1") await self.write(data) await self.write(bytes([0])) async def write_utf(self, value: str, /) -> None: """Write a UTF-8 encoded string prefixed with its byte length as a varint. The maximum number of characters allowed is ``32767``. Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the worst case of 4 bytes per every character, at most 131068 data bytes will be written + 3 additional bytes from the varint encoding overhead. :param value: String to encode. :raises ValueError: If the string exceeds the maximum allowed length. """ if len(value) > 32767: raise ValueError("Maximum character limit for writing strings is 32767 characters.") data = bytes(value, "utf-8") await self.write_varint(len(data)) await self.write(data) async def write_optional(self, value: T | None, /, writer: Callable[[T], Awaitable[R]]) -> R | None: """Write a bool indicating whether ``value`` is present and, if so, serialize the value using ``writer``. * If ``value`` is ``None``, ``False`` is written and ``None`` is returned. * If ``value`` is not ``None``, ``True`` is written and ``writer`` is called with the value. The return value of ``writer`` is then forwarded. :param value: Optional value to serialize. :param writer: Callable used to serialize the value when it is present. :return: ``None`` if the value is absent, otherwise the result of ``writer``. """ if value is None: await self.write_value(StructFormat.BOOL, False) # ruff: ignore[boolean-positional-value-in-call] return None await self.write_value(StructFormat.BOOL, True) # ruff: ignore[boolean-positional-value-in-call] return await writer(value) class BaseSyncWriter(ABC): """Base class holding synchronous write buffer/connection interactions.""" __slots__: ClassVar[tuple[str, ...]] = () @abstractmethod def write(self, data: bytes | bytearray, /) -> None: """Underlying write method, sending/storing the data. All of the writer functions will eventually call this method. """ @overload def write_value(self, fmt: INT_FORMATS_TYPE, value: int, /) -> None: ... @overload def write_value(self, fmt: FLOAT_FORMATS_TYPE, value: float, /) -> None: ... @overload def write_value(self, fmt: Literal[StructFormat.BOOL], value: bool, /) -> None: ... # ruff: ignore[boolean-type-hint-positional-argument] @overload def write_value(self, fmt: Literal[StructFormat.CHAR], value: bytes, /) -> None: ... def write_value(self, fmt: StructFormat, value: object, /) -> None: """Write a given ``value`` as given struct format (``fmt``) in big-endian mode.""" self.write(struct.pack(">" + fmt.value, value)) def _write_varuint(self, value: int, /, *, max_bits: int | None = None) -> None: """Write an arbitrarily large unsigned integer using a variable-length encoding. This is a standard way of transmitting integers with variable length over the network, allowing smaller numbers take up fewer bytes. Writing is limited to integers representable within ``max_bits`` bits. Attempting to write a larger value will raise :class:`ValueError`. Note that limiting the value to e.g. 32 bits does not mean that at most 4 bytes will be written. Due to the encoding overhead, values within 32 bits may require up to 5 bytes. Varints encode integers using groups of 7 bits. The 7 least significant bits of each byte store data, while the most significant bit acts as a continuation flag. If this bit is set (``1``), another byte follows. The least significant group is written first, followed by increasingly significant groups, making the encoding little-endian in 7-bit groups. :param value: Unsigned integer to encode. :param max_bits: Maximum allowed bit width for the encoded value. :raises ValueError: If ``value`` is negative or exceeds the allowed bit width. """ value_max = (1 << (max_bits)) - 1 if max_bits is not None else float("inf") if value < 0 or value > value_max: raise ValueError(f"Tried to write varint outside of the range of {max_bits}-bit int.") remaining = value while True: if remaining & ~0x7F == 0: # final byte self.write_value(StructFormat.UBYTE, remaining) return # Write only 7 least significant bits with the first bit being 1, marking there will be another byte self.write_value(StructFormat.UBYTE, remaining & 0x7F | 0x80) # Subtract the value we've already sent (7 least significant bits) remaining >>= 7 def write_varint(self, value: int, /) -> None: """Write a 32-bit signed integer using a variable-length encoding. The value is first converted to a 32-bit two's complement representation and then encoded using the varint format. See :meth:`_write_varuint` for details about the encoding. :param value: Signed integer to encode. """ val = to_twos_complement(value, bits=32) self._write_varuint(val, max_bits=32) def write_varlong(self, value: int, /) -> None: """Write a 64-bit signed integer using a variable-length encoding. The value is first converted to a 64-bit two's complement representation and then encoded using the varint format. See :meth:`_write_varuint` for details about the encoding. :param value: Signed integer to encode. """ val = to_twos_complement(value, bits=64) self._write_varuint(val, max_bits=64) def write_bytearray(self, data: bytes | bytearray, /) -> None: """Write an arbitrary sequence of bytes, prefixed with a varint of its size.""" self.write_varint(len(data)) self.write(data) def write_ascii(self, value: str, /) -> None: """Write ISO-8859-1 encoded string, with NULL (0x00) at the end to indicate string end.""" data = bytes(value, "ISO-8859-1") self.write(data) self.write(bytes([0])) def write_utf(self, value: str, /) -> None: """Write a UTF-8 encoded string prefixed with its byte length as a varint. The maximum number of characters allowed is ``32767``. Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the worst case of 4 bytes per every character, at most 131068 data bytes will be written + 3 additional bytes from the varint encoding overhead. :param value: String to encode. :raises ValueError: If the string exceeds the maximum allowed length. """ if len(value) > 32767: raise ValueError("Maximum character limit for writing strings is 32767 characters.") data = bytes(value, "utf-8") self.write_varint(len(data)) self.write(data) def write_optional(self, value: T | None, /, writer: Callable[[T], R]) -> R | None: """Write a bool indicating whether ``value`` is present and, if so, serialize the value using ``writer``. * If ``value`` is ``None``, ``False`` is written and ``None`` is returned. * If ``value`` is not ``None``, ``True`` is written and ``writer`` is called with the value. The return value of ``writer`` is then forwarded. :param value: Optional value to serialize. :param writer: Callable used to serialize the value when it is present. :return: ``None`` if the value is absent, otherwise the result of ``writer``. """ if value is None: self.write_value(StructFormat.BOOL, False) # ruff: ignore[boolean-positional-value-in-call] return None self.write_value(StructFormat.BOOL, True) # ruff: ignore[boolean-positional-value-in-call] return writer(value) # endregion # region: Reader classes class BaseAsyncReader(ABC): """Base class holding asynchronous read buffer/connection interactions.""" __slots__: ClassVar[tuple[str, ...]] = () @abstractmethod async def read(self, length: int, /) -> bytes: """Underlying read method, obtaining the raw data. All of the reader functions will eventually call this method. """ @overload async def read_value(self, fmt: INT_FORMATS_TYPE, /) -> int: ... @overload async def read_value(self, fmt: FLOAT_FORMATS_TYPE, /) -> float: ... @overload async def read_value(self, fmt: Literal[StructFormat.BOOL], /) -> bool: ... @overload async def read_value(self, fmt: Literal[StructFormat.CHAR], /) -> bytes: ... async def read_value(self, fmt: StructFormat, /) -> object: """Read a value as given struct format (``fmt``) in big-endian mode. The amount of bytes to read will be determined based on the struct format automatically. """ length = struct.calcsize(fmt.value) data = await self.read(length) unpacked = struct.unpack(">" + fmt.value, data) return unpacked[0] async def _read_varuint(self, *, max_bits: int | None = None) -> int: """Read an arbitrarily large unsigned integer using a variable-length encoding. This is a standard way of transmitting integers with variable length over the network, allowing smaller numbers to take fewer bytes. Reading is limited to integers representable within ``max_bits`` bits. Attempting to read a larger value will raise :class:`OSError`. Note that limiting the value to e.g. 32 bits does not mean that at most 4 bytes will be read. Due to the encoding overhead, values within 32 bits may require up to 5 bytes. Varints encode integers using groups of 7 bits. The 7 least significant bits of each byte store data, while the most significant bit acts as a continuation flag. If this bit is set (``1``), another byte follows. The least significant group is read first, followed by increasingly significant groups, making the encoding little-endian in 7-bit groups. :param max_bits: Maximum allowed bit width for the decoded value. :raises OSError: * If the decoded value exceeds the allowed bit width. * If more bytes are received than can possibly encode a value constrained by ``max_bits``. :return: The decoded unsigned integer. """ value_max = (1 << (max_bits)) - 1 if max_bits is not None else float("inf") # Varints carry 7 value bits per byte, so this is the exact maximum encoded length. byte_limit = math.ceil(max_bits / 7) if max_bits is not None else None result = 0 i = 0 while byte_limit is None or i < byte_limit: byte = await self.read_value(StructFormat.UBYTE) # Read 7 least significant value bits in this byte, and shift them appropriately to be in the right place # then simply add them (OR) as additional 7 most significant bits in our result result |= (byte & 0x7F) << (7 * i) # Ensure that we stop reading and raise an error if the size gets over the maximum # (if the current amount of bits is higher than allowed size in bits) if result > value_max: raise OSError(f"Received varint was outside the range of {max_bits}-bit int.") # If the most significant bit is 0, we should stop reading if not byte & 0x80: break i += 1 else: raise OSError( f"Received varint had too many bytes for {max_bits}-bit int (continuation bit set on byte {byte_limit})." ) return result async def read_varint(self) -> int: """Read a 32-bit signed integer using a variable-length encoding. The value is read as an unsigned varint and then converted from a 32-bit two's complement representation. See :meth:`_read_varuint` for details about the encoding. :return: Decoded signed integer. """ unsigned_num = await self._read_varuint(max_bits=32) return from_twos_complement(unsigned_num, bits=32) async def read_varlong(self) -> int: """Read a 64-bit signed integer using a variable-length encoding. The value is read as an unsigned varint and then converted from a 64-bit two's complement representation. See :meth:`_read_varuint` for details about the encoding. :return: Decoded signed integer. """ unsigned_num = await self._read_varuint(max_bits=64) return from_twos_complement(unsigned_num, bits=64) async def read_bytearray(self, /) -> bytes: """Read a sequence of bytes prefixed with its length encoded as a varint. :raises OSError: If the encoded length prefix is negative. :return: The decoded byte sequence. """ length = await self.read_varint() if length < 0: raise OSError(f"Length prefix for byte arrays must be non-negative, got {length}.") return await self.read(length) async def read_ascii(self) -> str: """Read ISO-8859-1 encoded string, until we encounter NULL (0x00) at the end indicating string end. Bytes are read until a NULL terminator is encountered. The terminator itself is not included in the returned string. There is no limit that can be set for how long this string can end up being. :return: Decoded string. """ # Keep reading bytes until we find NULL result = bytearray() while len(result) == 0 or result[-1] != 0: byte = await self.read(1) result.extend(byte) return result[:-1].decode("ISO-8859-1") async def read_utf(self) -> str: """Read a UTF-8 encoded string prefixed with its byte length as a varint. The maximum number of characters allowed is ``32767``. Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the worst case of 4 bytes per every character, at most 131068 data bytes will be read + 3 additional bytes from the varint encoding overhead. :raises OSError: * If the length prefix is negative. * If the length prefix exceeds the maximum of ``131068``, the string will not be read at all, and the error will be raised immediately after reading the prefix. * If the decoded string contains more than ``32767`` characters. In this case the data is still fully read because it fits within the byte limit. This behavior mirrors Minecraft's implementation. :return: Decoded UTF-8 string. """ length = await self.read_varint() if length < 0: raise OSError(f"Length prefix for utf strings must be non-negative, got {length}.") if length > 131068: raise OSError(f"Maximum read limit for utf strings is 131068 bytes, got {length}.") data = await self.read(length) chars = data.decode("utf-8") if len(chars) > 32767: raise OSError(f"Maximum read limit for utf strings is 32767 characters, got {len(chars)}.") return chars async def read_optional(self, reader: Callable[[], Awaitable[R]]) -> R | None: """Read a boolean indicating whether a value is present and, if so, deserialize it using ``reader``. * If ``False`` is read, nothing further is read and ``None`` is returned. * If ``True`` is read, ``reader`` is called and its return value is forwarded. :param reader: Callable used to read the value when it is present. :return: ``None`` if the value is absent, otherwise the result of ``reader``. """ if not await self.read_value(StructFormat.BOOL): return None return await reader() class BaseSyncReader(ABC): """Base class holding synchronous read buffer/connection interactions.""" __slots__: ClassVar[tuple[str, ...]] = () @abstractmethod def read(self, length: int, /) -> bytes: """Underlying read method, obtaining the raw data. All of the reader functions will eventually call this method. """ @overload def read_value(self, fmt: INT_FORMATS_TYPE, /) -> int: ... @overload def read_value(self, fmt: FLOAT_FORMATS_TYPE, /) -> float: ... @overload def read_value(self, fmt: Literal[StructFormat.BOOL], /) -> bool: ... @overload def read_value(self, fmt: Literal[StructFormat.CHAR], /) -> bytes: ... def read_value(self, fmt: StructFormat, /) -> object: """Read a value as given struct format (``fmt``) in big-endian mode. The amount of bytes to read will be determined based on the struct format automatically. """ length = struct.calcsize(fmt.value) data = self.read(length) unpacked = struct.unpack(">" + fmt.value, data) return unpacked[0] def _read_varuint(self, *, max_bits: int | None = None) -> int: """Read an arbitrarily large unsigned integer using a variable-length encoding. This is a standard way of transmitting integers with variable length over the network, allowing smaller numbers to take fewer bytes. Reading is limited to integers representable within ``max_bits`` bits. Attempting to read a larger value will raise :class:`OSError`. Note that limiting the value to e.g. 32 bits does not mean that at most 4 bytes will be read. Due to the encoding overhead, values within 32 bits may require up to 5 bytes. Varints encode integers using groups of 7 bits. The 7 least significant bits of each byte store data, while the most significant bit acts as a continuation flag. If this bit is set (``1``), another byte follows. The least significant group is read first, followed by increasingly significant groups, making the encoding little-endian in 7-bit groups. :param max_bits: Maximum allowed bit width for the decoded value. :raises OSError: * If the decoded value exceeds the allowed bit width. * If more bytes are received than can possibly encode a value constrained by ``max_bits``. :return: The decoded unsigned integer. """ value_max = (1 << (max_bits)) - 1 if max_bits is not None else float("inf") # Varints carry 7 value bits per byte, so this is the exact maximum encoded length. byte_limit = math.ceil(max_bits / 7) if max_bits is not None else None result = 0 i = 0 while byte_limit is None or i < byte_limit: byte = self.read_value(StructFormat.UBYTE) # Read 7 least significant value bits in this byte, and shift them appropriately to be in the right place # then simply add them (OR) as additional 7 most significant bits in our result result |= (byte & 0x7F) << (7 * i) # Ensure that we stop reading and raise an error if the size gets over the maximum # (if the current amount of bits is higher than allowed size in bits) if result > value_max: raise OSError(f"Received varint was outside the range of {max_bits}-bit int.") # If the most significant bit is 0, we should stop reading if not byte & 0x80: break i += 1 else: raise OSError( f"Received varint had too many bytes for {max_bits}-bit int (continuation bit set on byte {byte_limit})." ) return result def read_varint(self) -> int: """Read a 32-bit signed integer using a variable-length encoding. The value is read as an unsigned varint and then converted from a 32-bit two's complement representation. See :meth:`_read_varuint` for details about the encoding. :return: Decoded signed integer. """ unsigned_num = self._read_varuint(max_bits=32) return from_twos_complement(unsigned_num, bits=32) def read_varlong(self) -> int: """Read a 64-bit signed integer using a variable-length encoding. The value is read as an unsigned varint and then converted from a 64-bit two's complement representation. See :meth:`_read_varuint` for details about the encoding. :return: Decoded signed integer. """ unsigned_num = self._read_varuint(max_bits=64) return from_twos_complement(unsigned_num, bits=64) def read_bytearray(self) -> bytes: """Read a sequence of bytes prefixed with its length encoded as a varint. :raises OSError: If the encoded length prefix is negative. :return: The decoded byte sequence. """ length = self.read_varint() if length < 0: raise OSError(f"Length prefix for byte arrays must be non-negative, got {length}.") return self.read(length) def read_ascii(self) -> str: """Read ISO-8859-1 encoded string, until we encounter NULL (0x00) at the end indicating string end. Bytes are read until a NULL terminator is encountered. The terminator itself is not included in the returned string. There is no limit that can be set for how long this string can end up being. :return: Decoded string. """ # Keep reading bytes until we find NULL result = bytearray() while len(result) == 0 or result[-1] != 0: byte = self.read(1) result.extend(byte) return result[:-1].decode("ISO-8859-1") def read_utf(self) -> str: """Read a UTF-8 encoded string prefixed with its byte length as a varint. The maximum number of characters allowed is ``32767``. Individual UTF-8 characters can take up to 4 bytes, however most of the common ones take up less. Assuming the worst case of 4 bytes per every character, at most 131068 data bytes will be read + 3 additional bytes from the varint encoding overhead. :raises OSError: * If the length prefix is negative. * If the length prefix exceeds the maximum of ``131068``, the string will not be read at all, and the error will be raised immediately after reading the prefix. * If the decoded string contains more than ``32767`` characters. In this case the data is still fully read because it fits within the byte limit. This behavior mirrors Minecraft's implementation. :return: Decoded UTF-8 string. """ length = self.read_varint() if length < 0: raise OSError(f"Length prefix for utf strings must be non-negative, got {length}.") if length > 131068: raise OSError(f"Maximum read limit for utf strings is 131068 bytes, got {length}.") data = self.read(length) chars = data.decode("utf-8") if len(chars) > 32767: raise OSError(f"Maximum read limit for utf strings is 32767 characters, got {len(chars)}.") return chars def read_optional(self, reader: Callable[[], R]) -> R | None: """Read a boolean indicating whether a value is present and, if so, deserialize it using ``reader``. * If ``False`` is read, nothing further is read and ``None`` is returned. * If ``True`` is read, ``reader`` is called and its return value is forwarded. :param reader: Callable used to read the value when it is present. :return: ``None`` if the value is absent, otherwise the result of ``reader``. """ if not self.read_value(StructFormat.BOOL): return None return reader() # endregion py-mine-mcstatus-341e7a4/mcstatus/_protocol/io/buffer.py000066400000000000000000000103071524603215100233600ustar00rootroot00000000000000from __future__ import annotations from typing import Any, SupportsIndex, TYPE_CHECKING, final, overload from mcstatus._protocol.io.base_io import BaseSyncReader, BaseSyncWriter if TYPE_CHECKING: from collections.abc import Iterable from _typeshed import ReadableBuffer from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = ["Buffer"] @final class Buffer(BaseSyncWriter, BaseSyncReader, bytearray): """In-memory bytearray-like buffer supporting the common read/write operations.""" __slots__ = ("pos",) @overload def __init__(self) -> None: ... @overload def __init__(self, ints: Iterable[SupportsIndex] | SupportsIndex | ReadableBuffer, /) -> None: ... @overload def __init__(self, string: str, /, encoding: str, errors: str = "strict") -> None: ... def __init__(self, *args: Any, **kwargs: Any): super().__init__(*args, **kwargs) self.pos = 0 @override def write(self, data: bytes | bytearray, /) -> None: """Write/Store given ``data`` into the buffer.""" self.extend(data) @override def read(self, length: int, /) -> bytes: """Read data stored in the buffer. Reading data doesn't remove that data, rather that data is treated as already read, and next read will start from the first unread byte. If freeing the data is necessary, check the :meth:`clear` method. :param length: Amount of bytes to be read. If the requested amount can't be read (buffer doesn't contain that much data/buffer doesn't contain any data), an :exc:`OSError` will be raised. If there were some data in the buffer, but it was less than requested, this remaining data will still be depleted and the partial data that was read will be a part of the error message in the :exc:`OSError`. This behavior is here to mimic reading from a real socket connection. If ``length`` is negative, an :exc:`OSError` will be raised. """ if length < 0: raise OSError(f"Requested to read a negative amount of data: {length}.") end = self.pos + length if end > len(self): data = bytearray(self.unread_view()) bytes_read = len(self) - self.pos self.pos = len(self) raise OSError( "Requested to read more data than available." f" Read {bytes_read} bytes: {data}, out of {length} requested bytes." ) try: return bytes(self.unread_view()[:length]) finally: self.pos = end @override def clear(self, *, only_already_read: bool = False) -> None: """Clear out the stored data and reset position. :param only_already_read: When set to ``True``, only the data that was already marked as read will be cleared, and the position will be reset (to start at the remaining data). This can be useful for avoiding needlessly storing large amounts of data in memory, if this data is no longer useful. Otherwise, if set to ``False``, all of the data is cleared, and the position is reset, essentially resulting in a blank buffer. """ if only_already_read: del self[: self.pos] else: super().clear() self.pos = 0 def reset(self) -> None: """Reset the position in the buffer. Since the buffer doesn't automatically clear the already read data, it is possible to simply reset the position and read the data it contains again. """ self.pos = 0 def unread_view(self) -> memoryview: """Return a zero-copy view of unread data without modifying buffer state.""" return memoryview(self)[self.pos :] def flush(self) -> bytes: """Read all of the remaining data in the buffer and clear it out.""" data = bytes(self.unread_view()) self.clear() return data @property def remaining(self) -> int: """The amount of bytes that's still remaining in the buffer to be read.""" return len(self) - self.pos py-mine-mcstatus-341e7a4/mcstatus/_protocol/io/connection.py000066400000000000000000000200651524603215100242500ustar00rootroot00000000000000from __future__ import annotations import asyncio import errno import socket from abc import ABC from ipaddress import ip_address from typing import ClassVar, TYPE_CHECKING, TypeAlias, final import asyncio_dgram from mcstatus._protocol.io.base_io import BaseAsyncReader, BaseAsyncWriter, BaseSyncReader, BaseSyncWriter if TYPE_CHECKING: from collections.abc import Iterable from typing_extensions import Self, SupportsIndex, override from mcstatus._net.address import Address else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "BaseAsyncConnection", "BaseSyncConnection", "TCPAsyncSocketConnection", "TCPSocketConnection", "UDPAsyncSocketConnection", "UDPSocketConnection", ] BytesConvertable: TypeAlias = "SupportsIndex | Iterable[SupportsIndex]" class BaseSyncConnection(BaseSyncReader, BaseSyncWriter, ABC): """Base synchronous read and write class.""" __slots__: ClassVar[tuple[str, ...]] = () class BaseAsyncConnection(BaseAsyncReader, BaseAsyncWriter, ABC): """Base asynchronous read and write class.""" __slots__: ClassVar[tuple[str, ...]] = () class _SocketConnection(BaseSyncConnection, ABC): """Socket connection.""" __slots__: ClassVar[tuple[str, ...]] = ("socket",) def __init__(self) -> None: # These will only be None until connect is called, ignore the None type assignment self.socket: socket.socket = None # pyright: ignore[reportAttributeAccessIssue] def close(self) -> None: """Close :attr:`.socket`.""" if self.socket is not None: # If initialized # pyright: ignore[reportUnnecessaryComparison] try: self.socket.shutdown(socket.SHUT_RDWR) except OSError as exception: # Socket wasn't connected (nothing to shut down) if exception.errno != errno.ENOTCONN: raise self.socket.close() def __enter__(self) -> Self: return self def __exit__(self, *_: object) -> None: self.close() @final class TCPSocketConnection(_SocketConnection): """TCP Connection to address. Timeout defaults to 3 seconds.""" __slots__ = () def __init__(self, addr: tuple[str | None, int], timeout: float = 3) -> None: super().__init__() self.socket = socket.create_connection(addr, timeout=timeout) self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) @override def read(self, length: int, /) -> bytes: """Return length bytes read from :attr:`.socket`. Raises :exc:`OSError` when server doesn't respond.""" result = bytearray() while len(result) < length: new = self.socket.recv(length - len(result)) if len(new) == 0: raise OSError("Server did not respond with any information!") result.extend(new) return bytes(result) @override def write(self, data: bytes | bytearray, /) -> None: """Send data on :attr:`.socket`.""" self.socket.sendall(data) @final class UDPSocketConnection(_SocketConnection): """UDP Connection class.""" __slots__ = ("addr",) def __init__(self, addr: Address, timeout: float = 3) -> None: super().__init__() self.addr = addr self.socket = socket.socket( socket.AF_INET if ip_address(addr[0]).version == 4 else socket.AF_INET6, socket.SOCK_DGRAM, ) self.socket.settimeout(timeout) @property def remaining(self) -> int: """Always return ``65535`` (``2 ** 16 - 1``).""" return 65535 @override def read(self, _length: int, /) -> bytes: """Return up to :meth:`.remaining` bytes. Length does nothing here.""" result = b"" while len(result) == 0: result = self.socket.recvfrom(self.remaining)[0] return result @override def write(self, data: bytes | bytearray, /) -> None: """Use :attr:`.socket` to send data to :attr:`.addr`.""" _ = self.socket.sendto(data, self.addr) @final class TCPAsyncSocketConnection(BaseAsyncConnection): """Asynchronous TCP Connection class.""" __slots__ = ("_addr", "reader", "timeout", "writer") def __init__(self, addr: Address, timeout: float = 3) -> None: # These will only be None until connect is called, ignore the None type assignment self.reader: asyncio.StreamReader = None # pyright: ignore[reportAttributeAccessIssue] self.writer: asyncio.StreamWriter = None # pyright: ignore[reportAttributeAccessIssue] self.timeout: float = timeout self._addr = addr async def connect(self) -> None: """Use :mod:`asyncio` to open a connection to address. Timeout is in seconds.""" conn = asyncio.open_connection(*self._addr) self.reader, self.writer = await asyncio.wait_for(conn, timeout=self.timeout) if self.writer is not None: # it might be None in unittest # pyright: ignore[reportUnnecessaryComparison] sock: socket.socket = self.writer.transport.get_extra_info("socket") sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) @override async def read(self, length: int, /) -> bytes: """Read up to ``length`` bytes from :attr:`.reader`.""" result = bytearray() while len(result) < length: new = await asyncio.wait_for(self.reader.read(length - len(result)), timeout=self.timeout) if len(new) == 0: # No information at all if len(result) == 0: raise OSError("Server did not respond with any information!") # We did get a few bytes, but we requested more raise OSError( f"Server stopped responding (got {len(result)} bytes, but expected {length} bytes)." f" Partial obtained data: {result!r}" ) result.extend(new) return bytes(result) @override async def write(self, data: bytes | bytearray, /) -> None: """Write data to :attr:`.writer`.""" self.writer.write(data) await self.writer.drain() async def close(self) -> None: """Close :attr:`.writer`.""" if self.writer is not None: # pyright: ignore[reportUnnecessaryComparison] # If initialized self.writer.close() await self.writer.wait_closed() async def __aenter__(self) -> Self: await self.connect() return self async def __aexit__(self, *_: object) -> None: await self.close() @final class UDPAsyncSocketConnection(BaseAsyncConnection): """Asynchronous UDP Connection class.""" __slots__ = ("_addr", "stream", "timeout") def __init__(self, addr: Address, timeout: float = 3) -> None: # This will only be None until connect is called, ignore the None type assignment self.stream: asyncio_dgram.aio.DatagramClient = None # pyright: ignore[reportAttributeAccessIssue] self.timeout: float = timeout self._addr = addr async def connect(self) -> None: """Connect to address. Timeout is in seconds.""" conn = asyncio_dgram.connect(self._addr) self.stream = await asyncio.wait_for(conn, timeout=self.timeout) @property def remaining(self) -> int: """Always return ``65535`` (``2 ** 16 - 1``).""" return 65535 @override async def read(self, _length: int, /) -> bytes: """Read from :attr:`.stream`. Length does nothing here.""" data, _remote_addr = await asyncio.wait_for(self.stream.recv(), timeout=self.timeout) return data @override async def write(self, data: bytes | bytearray, /) -> None: """Send data with :attr:`.stream`.""" await self.stream.send(bytes(data)) def close(self) -> None: """Close :attr:`.stream`.""" if self.stream is not None: # pyright: ignore[reportUnnecessaryComparison] # If initialized self.stream.close() async def __aenter__(self) -> Self: await self.connect() return self async def __aexit__(self, *_: object) -> None: self.close() py-mine-mcstatus-341e7a4/mcstatus/_protocol/java_client.py000066400000000000000000000133061524603215100237610ustar00rootroot00000000000000from __future__ import annotations import json import random from abc import ABC, abstractmethod from dataclasses import dataclass from time import perf_counter from typing import TYPE_CHECKING, final from mcstatus._protocol.io.base_io import StructFormat from mcstatus._protocol.io.buffer import Buffer from mcstatus.responses import JavaStatusResponse if TYPE_CHECKING: from collections.abc import Awaitable from typing_extensions import override from mcstatus._net.address import Address from mcstatus._protocol.io.connection import TCPAsyncSocketConnection, TCPSocketConnection from mcstatus.responses._raw import RawJavaResponse else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = ["AsyncJavaClient", "JavaClient"] @dataclass class _BaseJavaClient(ABC): connection: TCPSocketConnection | TCPAsyncSocketConnection address: Address version: int """Version of the client.""" ping_token: int = None # pyright: ignore[reportAssignmentType] """Token that is used for the request, default is random number.""" def __post_init__(self) -> None: if self.ping_token is None: # pyright: ignore[reportUnnecessaryComparison] self.ping_token = random.randint(0, (1 << 63) - 1) def _build_handshake_packet(self) -> Buffer: """Build the initial handshake packet.""" packet = Buffer() packet.write_varint(0) packet.write_varint(self.version) packet.write_utf(self.address.host) packet.write_value(StructFormat.USHORT, self.address.port) packet.write_varint(1) # Intention to query status return packet @abstractmethod def read_status(self) -> JavaStatusResponse | Awaitable[JavaStatusResponse]: """Make a status request and parse the response.""" raise NotImplementedError @abstractmethod def test_ping(self) -> float | Awaitable[float]: """Send a ping token and measure the latency.""" raise NotImplementedError def _handle_status_response(self, response: Buffer, start: float, end: float) -> JavaStatusResponse: """Given a response buffer (already read from connection), parse and build the JavaStatusResponse.""" if response.read_varint() != 0: raise OSError("Received invalid status response packet.") try: raw: RawJavaResponse = json.loads(response.read_utf()) except ValueError as e: raise OSError("Received invalid JSON") from e try: latency_ms = (end - start) * 1000 return JavaStatusResponse.build(raw, latency=latency_ms) except KeyError as e: raise OSError("Received invalid status response") from e def _handle_ping_response(self, response: Buffer, start: float, end: float) -> float: """Given a ping response buffer, validate token and compute latency.""" if response.read_varint() != 1: raise OSError("Received invalid ping response packet.") received_token = response.read_value(StructFormat.LONGLONG) if received_token != self.ping_token: raise OSError(f"Received mangled ping response (expected token {self.ping_token}, got {received_token})") return (end - start) * 1000 @final @dataclass class JavaClient(_BaseJavaClient): connection: TCPSocketConnection # pyright: ignore[reportIncompatibleVariableOverride] def handshake(self) -> None: """Write the initial handshake packet to the connection.""" self.connection.write_bytearray(self._build_handshake_packet()) @override def read_status(self) -> JavaStatusResponse: """Send the status request and read the response.""" request = Buffer() request.write_varint(0) # Request status self.connection.write_bytearray(request) start = perf_counter() response = Buffer(self.connection.read_bytearray()) end = perf_counter() return self._handle_status_response(response, start, end) @override def test_ping(self) -> float: """Send a ping token and measure the latency.""" request = Buffer() request.write_varint(1) # Test ping request.write_value(StructFormat.LONGLONG, self.ping_token) start = perf_counter() self.connection.write_bytearray(request) response = Buffer(self.connection.read_bytearray()) end = perf_counter() return self._handle_ping_response(response, start, end) @final @dataclass class AsyncJavaClient(_BaseJavaClient): connection: TCPAsyncSocketConnection # pyright: ignore[reportIncompatibleVariableOverride] async def handshake(self) -> None: """Write the initial handshake packet to the connection.""" await self.connection.write_bytearray(self._build_handshake_packet()) @override async def read_status(self) -> JavaStatusResponse: """Send the status request and read the response.""" request = Buffer() request.write_varint(0) # Request status await self.connection.write_bytearray(request) start = perf_counter() response = Buffer(await self.connection.read_bytearray()) end = perf_counter() return self._handle_status_response(response, start, end) @override async def test_ping(self) -> float: """Send a ping token and measure the latency.""" request = Buffer() request.write_varint(1) # Test ping request.write_value(StructFormat.LONGLONG, self.ping_token) start = perf_counter() await self.connection.write_bytearray(request) response = Buffer(await self.connection.read_bytearray()) end = perf_counter() return self._handle_ping_response(response, start, end) py-mine-mcstatus-341e7a4/mcstatus/_protocol/legacy_client.py000066400000000000000000000047061524603215100243100ustar00rootroot00000000000000from time import perf_counter from typing import ClassVar, final from mcstatus._protocol.io.base_io import StructFormat from mcstatus._protocol.io.connection import BaseAsyncConnection, BaseSyncConnection from mcstatus.responses import LegacyStatusResponse __all__ = ["AsyncLegacyClient", "LegacyClient"] class _BaseLegacyClient: request_status_data: ClassVar[bytes] = bytes.fromhex( # see https://minecraft.wiki/w/Java_Edition_protocol/Server_List_Ping#Client_to_server "fe01fa" ) @staticmethod def parse_response(data: bytes, latency: float) -> LegacyStatusResponse: decoded_data: list[str] = data.decode("UTF-16BE").split("\0") if decoded_data[0] != "§1": # kick packets before 1.4 (12w42a) did not start with §1 and did # not included information about server and protocol version decoded_data = ["§1", "-1", "<1.4", *decoded_data[0].split("§")] if len(decoded_data) != 6: raise OSError("Received invalid kick packet reason") return LegacyStatusResponse.build(decoded_data[1:], latency) @final class LegacyClient(_BaseLegacyClient): def __init__(self, connection: BaseSyncConnection) -> None: self.connection = connection def read_status(self) -> LegacyStatusResponse: """Send the status request and read the response.""" start = perf_counter() self.connection.write(self.request_status_data) id = self.connection.read(1) if id != b"\xff": raise OSError("Received invalid packet ID") length = self.connection.read_value(StructFormat.USHORT) data = self.connection.read(length * 2) end = perf_counter() return self.parse_response(data, (end - start) * 1000) @final class AsyncLegacyClient(_BaseLegacyClient): def __init__(self, connection: BaseAsyncConnection) -> None: self.connection = connection async def read_status(self) -> LegacyStatusResponse: """Send the status request and read the response.""" start = perf_counter() await self.connection.write(self.request_status_data) id = await self.connection.read(1) if id != b"\xff": raise OSError("Received invalid packet ID") length = await self.connection.read_value(StructFormat.USHORT) data = await self.connection.read(length * 2) end = perf_counter() return self.parse_response(data, (end - start) * 1000) py-mine-mcstatus-341e7a4/mcstatus/_protocol/query_client.py000066400000000000000000000126021524603215100242030ustar00rootroot00000000000000from __future__ import annotations import random import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any, ClassVar, TYPE_CHECKING, final from mcstatus._protocol.io.base_io import StructFormat from mcstatus._protocol.io.buffer import Buffer from mcstatus.responses import QueryResponse from mcstatus.responses._raw import RawQueryResponse __all__ = ["AsyncQueryClient", "QueryClient"] if TYPE_CHECKING: from collections.abc import Awaitable from typing_extensions import override from mcstatus._protocol.io.connection import UDPAsyncSocketConnection, UDPSocketConnection else: override = lambda f: f # ruff: ignore[lambda-assignment] @dataclass class _BaseQueryClient(ABC): MAGIC_PREFIX: ClassVar[bytearray] = bytearray.fromhex("FEFD") PADDING: ClassVar[bytearray] = bytearray.fromhex("00000000") PACKET_TYPE_CHALLENGE: ClassVar[int] = 9 PACKET_TYPE_QUERY: ClassVar[int] = 0 connection: UDPSocketConnection | UDPAsyncSocketConnection challenge: int = field(init=False, default=0) @staticmethod def _generate_session_id() -> int: # minecraft only supports lower 4 bits return random.randint(0, 2**31) & 0x0F0F0F0F def _create_packet(self) -> Buffer: packet = Buffer() packet.write(self.MAGIC_PREFIX) packet.write_value(StructFormat.UBYTE, self.PACKET_TYPE_QUERY) packet.write_value(StructFormat.UINT, self._generate_session_id()) packet.write_value(StructFormat.INT, self.challenge) packet.write(self.PADDING) return packet def _create_handshake_packet(self) -> Buffer: packet = Buffer() packet.write(self.MAGIC_PREFIX) packet.write_value(StructFormat.UBYTE, self.PACKET_TYPE_CHALLENGE) packet.write_value(StructFormat.UINT, self._generate_session_id()) return packet @abstractmethod def _read_packet(self) -> Buffer | Awaitable[Buffer]: raise NotImplementedError @abstractmethod def handshake(self) -> Awaitable[None] | None: raise NotImplementedError @abstractmethod def read_query(self) -> QueryResponse | Awaitable[QueryResponse]: raise NotImplementedError def _parse_response(self, response: Buffer) -> tuple[RawQueryResponse, list[str]]: """Transform the connection object (the result) into dict which is passed to the QueryResponse constructor. :return: A tuple with two elements. First is `raw` answer and second is list of players. """ _ = response.read(len("splitnum") + 3) data: dict[str, Any] = {} while True: key = response.read_ascii() if key == "hostname": # hostname is actually motd in the query protocol match = re.search( rb"(.*?)\x00(hostip|hostport|game_id|gametype|map|maxplayers|numplayers|plugins|version)", bytes(response.unread_view()), flags=re.DOTALL, ) motd = match.group(1) if match else "" # Since the query protocol does not properly support unicode, the motd is still not resolved # correctly; however, this will avoid other parameter parsing errors. data[key] = response.read(len(motd)).decode("ISO-8859-1") _ = response.read(1) # ignore null byte elif len(key) == 0: _ = response.read(1) break else: value = response.read_ascii() data[key] = value _ = response.read(len("player_") + 2) players_list: list[str] = [] while True: player = response.read_ascii() if len(player) == 0: break players_list.append(player) return RawQueryResponse(**data), players_list @final @dataclass class QueryClient(_BaseQueryClient): connection: UDPSocketConnection # pyright: ignore[reportIncompatibleVariableOverride] @override def _read_packet(self) -> Buffer: packet = Buffer(self.connection.read(self.connection.remaining)) _ = packet.read(1 + 4) return packet @override def handshake(self) -> None: self.connection.write(self._create_handshake_packet()) packet = self._read_packet() self.challenge = int(packet.read_ascii()) @override def read_query(self) -> QueryResponse: request = self._create_packet() self.connection.write(request) response = self._read_packet() return QueryResponse.build(*self._parse_response(response)) @final @dataclass class AsyncQueryClient(_BaseQueryClient): connection: UDPAsyncSocketConnection # pyright: ignore[reportIncompatibleVariableOverride] @override async def _read_packet(self) -> Buffer: packet = Buffer(await self.connection.read(self.connection.remaining)) _ = packet.read(1 + 4) return packet @override async def handshake(self) -> None: await self.connection.write(self._create_handshake_packet()) packet = await self._read_packet() self.challenge = int(packet.read_ascii()) @override async def read_query(self) -> QueryResponse: request = self._create_packet() await self.connection.write(request) response = await self._read_packet() return QueryResponse.build(*self._parse_response(response)) py-mine-mcstatus-341e7a4/mcstatus/_utils/000077500000000000000000000000001524603215100204245ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/_utils/__init__.py000066400000000000000000000003331524603215100225340ustar00rootroot00000000000000from mcstatus._utils.deprecation import deprecated, deprecation_warn from mcstatus._utils.general import or_none from mcstatus._utils.retry import retry __all__ = ["deprecated", "deprecation_warn", "or_none", "retry"] py-mine-mcstatus-341e7a4/mcstatus/_utils/deprecation.py000066400000000000000000000222561524603215100233020ustar00rootroot00000000000000from __future__ import annotations import functools import importlib.metadata import re import warnings from functools import wraps from typing import ParamSpec, Protocol, TYPE_CHECKING, TypeVar if TYPE_CHECKING: from collections.abc import Callable __all__ = ["deprecated", "deprecation_warn"] LIB_NAME = "mcstatus" # This comes from the python packaging docs (PEP 440 compliant versioning): # https://packaging.python.org/en/latest/specifications/version-specifiers/#appendix-parsing-version-strings-with-regular-expressions VERSION_PATTERN_FULL = re.compile( r"""^\s* v? (?: (?:(?P[0-9]+)!)? # epoch (?P[0-9]+(?:\.[0-9]+)*) # release segment (?P
                                          # pre-release
            [-_\.]?
            (?P(a|b|c|rc|alpha|beta|pre|preview))
            [-_\.]?
            (?P[0-9]+)?
        )?
        (?P                                         # post release
            (?:-(?P[0-9]+))
            |
            (?:
                [-_\.]?
                (?Ppost|rev|r)
                [-_\.]?
                (?P[0-9]+)?
            )
        )?
        (?P                                          # dev release
            [-_\.]?
            (?Pdev)
            [-_\.]?
            (?P[0-9]+)?
        )?
    )
    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
    \s*$""",
    re.VERBOSE | re.IGNORECASE,
)
# Intentionally restricted to X.Y.Z, unlike PEP 440 release segments.
# Used only for user-supplied removal_version values parsing.
REMOVAL_VERSION_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)")
DEPRECATED_DIRECTIVE_RE = re.compile(r"^\s*\.\.\s+deprecated::\s*", flags=re.MULTILINE)

P = ParamSpec("P")
R = TypeVar("R")


@functools.lru_cache(maxsize=1)
def _get_project_version() -> tuple[int, int, int]:
    """Return the installed project version normalized to a 3-part release tuple.

    The project version is obtained from :mod:`importlib.metadata` and parsed using the official PEP 440
    version parsing regular expression.

    All non-release components of the version (pre-releases, post-releases, development releases, and local
    version identifiers) are intentionally ignored. The release version segment of the version is then
    normalized to 3 components, padding with zeros if the actual version has less components, or truncating
    if it has more. Any performed normalizing will emit a :exc:`RuntimeWarning`.

    If the project version cannot be determined or parsed, ``(0, 0, 0)`` is returned and a runtime warning
    is emitted.
    """
    try:
        project_version = importlib.metadata.version(LIB_NAME)
    except importlib.metadata.PackageNotFoundError:
        # v0.0.0 will never mark things as already deprecated (removal_version will always be newer)
        warnings.warn(f"Failed to get {LIB_NAME} project version, assuming v0.0.0", category=RuntimeWarning, stacklevel=1)
        return (0, 0, 0)

    m = VERSION_PATTERN_FULL.fullmatch(project_version)
    if m is None:
        # This should never happen
        warnings.warn(
            f"Failed to parse {LIB_NAME} project version ({project_version}), assuming v0.0.0",
            category=RuntimeWarning,
            stacklevel=1,
        )
        return (0, 0, 0)

    if m["epoch"] is not None:
        # we're not using epoch, and we don't expect to start doing so. If we do, the rest of this
        # implementation would likely need to be changed anyways. Generally, this should never happen.
        warnings.warn(f"Failed to parse {LIB_NAME} project version, assuming v0.0.0", category=RuntimeWarning, stacklevel=1)
        return (0, 0, 0)

    release = m["release"]
    nums = [int(p) for p in release.split(".")]

    if len(nums) < 3:
        warnings.warn(
            f"{LIB_NAME} version '{release}' has less than 3 release components; remaining components will become zeroes",
            category=RuntimeWarning,
            stacklevel=2,
        )
        nums.extend([0] * (3 - len(nums)))
    elif len(nums) > 3:
        warnings.warn(
            f"{LIB_NAME} version '{release}' has more than 3 release components; extra components are ignored",
            category=RuntimeWarning,
            stacklevel=2,
        )
        nums = nums[:3]

    return nums[0], nums[1], nums[2]


def deprecation_warn(
    *,
    obj_name: str,
    removal_version: str | tuple[int, int, int],
    replacement: str | None = None,
    extra_msg: str | None = None,
    stack_level: int = 2,
) -> None:
    """Produce an appropriate deprecation warning given the parameters.

    If the currently installed project version is already past the specified deprecation version,
    a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get
    emitted as a warning.

    The deprecation message used will be constructed dynamically based on the input parameters.

    :param obj_name: Name of the object that got deprecated (such as ``my_function``).
    :param removal_version: Version at which this object should be considered as deprecated and should no longer be used.
    :param replacement: A new alternative to this (now deprecated) object.
    :param extra_msg: Additional message included in the deprecation warning/exception at the end.
    :param stack_level: Stack level at which the warning is emitted.

    .. note:
        If the project version contains any additional qualifiers (e.g. pre-release, post-release, dev/local versions),
        they will be ignored and the project version will be treated a simple stable (major, minor, micro) version.
    """
    if isinstance(removal_version, str):
        if m := REMOVAL_VERSION_RE.fullmatch(removal_version):
            removal_version = (int(m[1]), int(m[2]), int(m[3]))
        else:
            raise ValueError(f"removal_version must follow regex pattern of: {REMOVAL_VERSION_RE.pattern}")

    project_version = _get_project_version()
    already_deprecated = project_version >= removal_version

    msg = f"{obj_name}"
    removal_version_str = ".".join(str(num) for num in removal_version)
    if already_deprecated:
        msg += f" is passed its removal version ({removal_version_str})"
    else:
        msg += f" is deprecated and scheduled for removal in {removal_version_str}"

    if replacement is not None:
        msg += f", use {replacement} instead"

    msg += "."
    if extra_msg is not None:
        msg += f" ({extra_msg})"

    if already_deprecated:
        raise DeprecationWarning(msg)

    warnings.warn(msg, category=DeprecationWarning, stacklevel=stack_level)


class DecoratorFunction(Protocol):
    def __call__(self, /, func: Callable[P, R]) -> Callable[P, R]: ...


def deprecated(
    *,
    removal_version: str | tuple[int, int, int],
    display_name: str | None = None,
    replacement: str | None = None,
    extra_msg: str | None = None,
    no_docstring_check: bool = False,
) -> DecoratorFunction:
    """Mark an object as deprecated.

    Decorator version of :func:`deprecation_warn` function.

    If the currently installed project version is already past the specified deprecation version,
    a :exc:`DeprecationWarning` will be raised as a full exception. Otherwise it will just get
    emitted as a warning.

    The deprecation message used will be constructed based on the input parameters.

    :param display_name:
            Name of the object that got deprecated (such as `my_function`).

            By default, the object name is obtained automatically from ``__qualname__`` (falling back
            to ``__name__``) of the decorated object. Setting this explicitly will override this obtained
            name and the `display_name` will be used instead.
    :param removal_version: Version at which this object should be considered as deprecated and should no longer be used.
    :param replacement: A new alternative to this (now deprecated) object.
    :param extra_msg: Additional message included in the deprecation warning/exception at the end.
    :param no_docstring_check:
        Disable a runtime check for the docstring of the decorated object containing ``.. deprecated::``.

    .. note:
        If the project version contains any additional qualifiers (e.g. pre-release, post-release, dev/local versions),
        they will be ignored and the project version will be treated a simple stable (major, minor, micro) version.
    """

    def inner(func: Callable[P, R]) -> Callable[P, R]:
        obj_name = getattr(func, "__qualname__", func.__name__) if display_name is None else display_name

        if not no_docstring_check:
            obj_doc = func.__doc__ or ""
            if DEPRECATED_DIRECTIVE_RE.search(obj_doc) is None:
                raise ValueError("Deprecated object does not contain '.. deprecated::' sphinx directive in its docstring")

        @wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            deprecation_warn(
                obj_name=obj_name,
                removal_version=removal_version,
                replacement=replacement,
                extra_msg=extra_msg,
                stack_level=3,
            )
            return func(*args, **kwargs)

        return wrapper

    return inner
py-mine-mcstatus-341e7a4/mcstatus/_utils/general.py000066400000000000000000000020551524603215100224150ustar00rootroot00000000000000from typing import TypeVar

__all__ = ["or_none"]


T = TypeVar("T")


def or_none(*args: T) -> T | None:
    """Return the first non-None argument.

    This function is similar to the standard inline ``or`` operator, while
    treating falsey values (such as ``0``, ``''``, or ``False``) as valid
    results rather than skipping them. It only skips ``None`` values.

    This is useful when selecting between optional values that may be empty
    but still meaningful.

    Example:
        .. code-block:: py
            >>> or_none("", 0, "fallback")
            ''
            >>> or_none(None, None, "value")
            'value'
            >>> or_none(None, None)
            None

        This is often useful when working with dict.get, e.g.:

        .. code-block:: py
            >>> mydict = {"a": ""}
            >>> mydict.get("a") or mydict.get("b")
            None  # expected ''!
            >>> or_none(mydict.get("a"), mydict.get("b"))
            ''
    """
    for arg in args:
        if arg is not None:
            return arg
    return None
py-mine-mcstatus-341e7a4/mcstatus/_utils/retry.py000066400000000000000000000053611524603215100221500ustar00rootroot00000000000000from __future__ import annotations

import inspect
from functools import wraps
from typing import ParamSpec, TYPE_CHECKING, TypeVar, cast

if TYPE_CHECKING:
    from collections.abc import Callable

__all__ = ["retry"]

T = TypeVar("T")
R = TypeVar("R")
P = ParamSpec("P")
P2 = ParamSpec("P2")


def retry(tries: int, exceptions: tuple[type[BaseException]] = (Exception,)) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """Decorator that re-runs given function ``tries`` times if error occurs.

    The amount of tries will either be the value given to the decorator,
    or if tries is present in keyword arguments on function call, this
    specified value will take precedence.

    If the function fails even after all the retries, raise the last
    exception that the function raised.

    .. note::
        Even if the previous failures caused a different exception, this will only raise the last one.
    """  # ruff: ignore[non-imperative-mood]

    def decorate(func: Callable[P, R]) -> Callable[P, R]:
        @wraps(func)
        async def async_wrapper(
            *args: P.args,
            tries: int = tries,  # pyright: ignore[reportGeneralTypeIssues] # No support for adding kw-only args
            **kwargs: P.kwargs,
        ) -> R:
            last_exc: BaseException
            for _ in range(tries):
                try:
                    return await func(*args, **kwargs)  # pyright: ignore[reportGeneralTypeIssues, reportUnknownVariableType] # We know func is awaitable here
                except exceptions as exc:  # ruff: ignore[try-except-in-loop]
                    last_exc = exc
            # This won't actually be unbound
            raise last_exc  # pyright: ignore[reportGeneralTypeIssues,reportPossiblyUnboundVariable]

        @wraps(func)
        def sync_wrapper(
            *args: P.args,
            tries: int = tries,  # pyright: ignore[reportGeneralTypeIssues] # No support for adding kw-only args
            **kwargs: P.kwargs,
        ) -> R:
            last_exc: BaseException
            for _ in range(tries):
                try:
                    return func(*args, **kwargs)
                except exceptions as exc:  # ruff: ignore[try-except-in-loop]
                    last_exc = exc
            # This won't actually be unbound
            raise last_exc  # pyright: ignore[reportGeneralTypeIssues,reportPossiblyUnboundVariable]

        # We cast here since pythons typing doesn't support adding keyword-only arguments to signature
        # (Support for this was a rejected idea https://peps.python.org/pep-0612/#concatenating-keyword-parameters)
        if inspect.iscoroutinefunction(func):
            return cast("Callable[P, R]", async_wrapper)
        return cast("Callable[P, R]", sync_wrapper)

    return decorate
py-mine-mcstatus-341e7a4/mcstatus/motd/000077500000000000000000000000001524603215100200705ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/motd/__init__.py000066400000000000000000000001351524603215100222000ustar00rootroot00000000000000from __future__ import annotations

from mcstatus.motd._motd import Motd

__all__ = ["Motd"]
py-mine-mcstatus-341e7a4/mcstatus/motd/_motd.py000066400000000000000000000267711524603215100215610ustar00rootroot00000000000000from __future__ import annotations

import re
import typing as t
from dataclasses import dataclass

from mcstatus.motd._simplifies import get_unused_elements, squash_nearby_strings
from mcstatus.motd._transformers import AnsiTransformer, HtmlTransformer, MinecraftTransformer, PlainTransformer
from mcstatus.motd.components import (
    BedrockFormatting,
    BedrockMinecraftColor,
    InvalidFormatting,
    JavaFormatting,
    JavaMinecraftColor,
    ParsedMotdComponent,
    TranslationTag,
    WebColor,
)

if t.TYPE_CHECKING:
    from typing_extensions import Self

    from mcstatus.responses._raw import RawJavaResponseMotd, RawJavaResponseMotdWhenDict

__all__ = ["Motd"]

_MOTD_COLORS_RE = re.compile(r"([\xA7|&][0-9A-Z])", re.IGNORECASE)


@dataclass(frozen=True)
class Motd:
    """Represents parsed MOTD."""

    parsed: list[ParsedMotdComponent]
    """Parsed MOTD, which then will be transformed.

    Bases on this attribute, you can easily write your own MOTD-to-something parser.
    """
    raw: RawJavaResponseMotd
    """MOTD in raw format, returning back the received server response unmodified."""
    bedrock: bool = False
    """Is the server Bedrock Edition?"""

    @classmethod
    def parse(
        cls,
        raw: RawJavaResponseMotd,  # pyright: ignore[reportRedeclaration] # later, we overwrite the type
        *,
        bedrock: bool = False,
    ) -> Self:
        """Parse a raw MOTD to less raw MOTD (:attr:`.parsed` attribute).

        :param raw: Raw MOTD, directly from server.
        :param bedrock: Is server Bedrock Edition? Nothing changes here, just sets attribute.
        :returns: New :class:`.Motd` instance.
        """
        original_raw: RawJavaResponseMotd
        if isinstance(raw, str):
            original_raw = raw
        elif hasattr(raw, "copy"):
            original_raw = raw.copy()
        else:  # pragma: no cover
            # we should never reach this, unless the type expectation wasn't met.
            # in that case, the isinstance checks below will fail and this will end
            # in a TypeError
            original_raw = raw

        if isinstance(raw, list):
            raw: RawJavaResponseMotdWhenDict = {"extra": raw}

        if isinstance(raw, str):
            parsed = cls._parse_as_str(raw, bedrock=bedrock)
        elif isinstance(raw, dict):  # pyright: ignore[reportUnnecessaryIsInstance]
            parsed = cls._parse_as_dict(raw, bedrock=bedrock)
        else:
            raise TypeError(f"Expected list, string or dict data, got {raw.__class__!r} ({raw!r}), report this!")

        return cls(parsed, original_raw, bedrock)

    @staticmethod
    def _parse_as_str(raw: str, *, bedrock: bool = False) -> list[ParsedMotdComponent]:
        """Parse a MOTD when it's string.

        .. note::

            This method may return a lot of unnecessary noise, like extra
            :attr:`Formatting.RESET`. Use :meth:`Motd.simplify` to remove it.

        :param raw: Raw MOTD, directly from server.
        :param bedrock: Is server Bedrock Edition?
            If it isn't, ignores any color that starts with ``MATERIAL`` or
            :attr:`MinecraftColor.MINECOIN_GOLD`.
        :returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
        """
        parsed_motd: list[ParsedMotdComponent] = []

        color_enum = BedrockMinecraftColor if bedrock else JavaMinecraftColor
        formatting_enum = BedrockFormatting if bedrock else JavaFormatting

        split_raw = _MOTD_COLORS_RE.split(raw)
        for element in split_raw:
            if not element:
                continue

            clean_element = element.lstrip("&§").lower()
            standardized_element = element.replace("&", "§").lower()

            if standardized_element.startswith("§"):
                try:
                    parsed_motd.append(color_enum(clean_element))
                except ValueError:
                    try:
                        parsed_motd.append(formatting_enum(clean_element))
                    except ValueError:
                        parsed_motd.append(InvalidFormatting(clean_element))
            else:
                parsed_motd.append(element)

        return parsed_motd

    @classmethod
    def _parse_as_dict(
        cls,
        item: RawJavaResponseMotdWhenDict,
        *,
        bedrock: bool,
        auto_add: list[ParsedMotdComponent] | None = None,
    ) -> list[ParsedMotdComponent]:
        """Parse a MOTD when it's dict.

        :param item: :class:`dict` directly from the server.
        :param bedrock: Is the server Bedrock Edition?
            Nothing does here, just going to :meth:`._parse_as_str` while parsing ``text`` field.
        :param auto_add: Values to add on this item.
            Most time, this is :class:`Formatting` from top level.
        :returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
        """
        formatting_enum = BedrockFormatting if bedrock else JavaFormatting
        parsed_motd: list[ParsedMotdComponent] = auto_add if auto_add is not None else []

        if (color := item.get("color")) is not None:
            parsed_motd.append(cls._parse_color(color, bedrock=bedrock))

        for style_key, style_val in formatting_enum.__members__.items():
            lowered_style_key = style_key.lower()
            if item.get(lowered_style_key) is False:
                try:
                    parsed_motd.remove(style_val)
                except ValueError:
                    # some servers set the formatting keys to false here, even without it ever being set to true before
                    continue
            elif item.get(lowered_style_key) is not None:
                parsed_motd.append(style_val)

        if (text := item.get("text")) is not None:
            parsed_motd.extend(cls._parse_as_str(text, bedrock=bedrock))
        if (translate := item.get("translate")) is not None:
            parsed_motd.append(TranslationTag(translate))
        parsed_motd.append(formatting_enum.RESET)

        if "extra" in item:
            auto_add = list(filter(lambda e: type(e) is formatting_enum and e != formatting_enum.RESET, parsed_motd))

            for element in item["extra"]:
                parsed_motd.extend(
                    cls._parse_as_dict(element, bedrock=bedrock, auto_add=auto_add.copy())
                    if isinstance(element, dict)
                    else auto_add + cls._parse_as_str(element, bedrock=bedrock)
                )

        return parsed_motd

    @staticmethod
    def _parse_color(color: str, *, bedrock: bool) -> ParsedMotdComponent:
        """Parse a color string."""
        color_enum = BedrockMinecraftColor if bedrock else JavaMinecraftColor
        formatting_enum = BedrockFormatting if bedrock else JavaFormatting
        try:
            return color_enum[color.upper()]
        except KeyError:
            if color == "reset":
                # Minecraft servers actually can't return {"reset": True}, instead, they treat
                # reset as a color and set {"color": "reset"}. However logically, reset is
                # a formatting, and it resets both color and other formatting, so we use
                # `Formatting.RESET` here.
                #
                # see `color` field in
                # https://minecraft.wiki/w/Java_Edition_protocol/Chat?oldid=2763811#Shared_between_all_components
                return formatting_enum.RESET

            # Last attempt: try parsing as HTML (hex rgb) color. Some servers use these to
            # achieve gradients.
            try:
                return WebColor.from_hex(color)
            except ValueError as e:
                raise ValueError(f"Unable to parse color: {color!r}, report this!") from e

    def simplify(self) -> Self:
        """Create new MOTD without unused elements.

        After parsing, the MOTD may contain some unused elements, like empty strings, or formatting/colors
        that don't apply to anything. This method is responsible for creating a new motd with all such elements
        removed, providing a much cleaner representation.

        :returns: New simplified MOTD, with any unused elements removed.
        """
        parsed = self.parsed.copy()
        old_parsed: list[ParsedMotdComponent] | None = None

        while parsed != old_parsed:
            old_parsed = parsed.copy()
            unused_elements = get_unused_elements(parsed)
            parsed = [el for index, el in enumerate(parsed) if index not in unused_elements]

        parsed = squash_nearby_strings(parsed)
        return self.__class__(parsed, self.raw, bedrock=self.bedrock)

    def to_plain(self) -> str:
        """Get plain text from a MOTD, without any colors/formatting.

        Example:
            ``&0Hello &oWorld`` turns into ``Hello World``.
        """
        return PlainTransformer(bedrock=self.bedrock).transform(self.parsed)

    def to_minecraft(self) -> str:
        """Transform MOTD to the Minecraft representation.

        .. note:: This will always use ``§``, even if in original MOTD used ``&``.

        Example:
            .. code-block:: python

                >>> Motd.parse("&0Hello &oWorld")
                "§0Hello §oWorld"
        """
        return MinecraftTransformer(bedrock=self.bedrock).transform(self.parsed)

    def to_html(self) -> str:
        """Transform MOTD to the HTML format.

        The result is always wrapped in a ``

`` tag, if you need to remove it, just do ``result.removeprefix("

").removesuffix("

")``. .. note:: You should implement the "obfuscated" CSS class yourself using this snippet: .. code-block:: javascript const obfuscatedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`~!@#$%^&*()-_=+[]\\"';:<>,./?"; const obfuscatedElems = document.querySelectorAll(".obfuscated"); if (obfuscatedElems !== undefined) { const render = () => { obfuscatedElems.forEach((elem) => { let value = ""; for (let i = 0, l = elem.innerText.length; i < l; i++) { value += obfuscatedCharacters.charAt( Math.floor(Math.random() * obfuscatedCharacters.length), ); } elem.innerText = value; }); setTimeout(render, 50); }; render(); } Also do note that this formatting does not make sense with non-monospace fonts. Example: ``&6Hello&o from &rAnother &kWorld`` turns into .. code-block:: html

Hello from Another World

""" # ruff: ignore[escape-sequence-in-docstring] # Use `r"""` if any backslashes in a docstring return HtmlTransformer(bedrock=self.bedrock).transform(self.parsed) def to_ansi(self) -> str: """Transform MOTD to the ANSI 24-bit format. ANSI is mostly used for printing colored text in the terminal. "Obfuscated" formatting (``&k``) is shown as a blinking one. .. seealso:: https://en.wikipedia.org/wiki/ANSI_escape_code. """ return AnsiTransformer(bedrock=self.bedrock).transform(self.parsed) py-mine-mcstatus-341e7a4/mcstatus/motd/_simplifies.py000066400000000000000000000154011524603215100227460ustar00rootroot00000000000000from __future__ import annotations import typing as t from mcstatus.motd.components import ( AnyFormatting, AnyMinecraftColor, BedrockMinecraftColor, JavaMinecraftColor, ParsedMotdComponent, WebColor, ) if t.TYPE_CHECKING: from collections.abc import Sequence __all__ = [ "get_double_colors", "get_double_items", "get_end_non_text", "get_formatting_before_color", "get_meaningless_resets_and_colors", "get_unused_elements", "squash_nearby_strings", ] _PARSED_MOTD_COMPONENTS_TYPEVAR = t.TypeVar("_PARSED_MOTD_COMPONENTS_TYPEVAR", bound="list[ParsedMotdComponent]") def get_unused_elements(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Get indices of all items which are unused and can be safely removed from the MOTD. This is a wrapper method around several unused item collection methods. """ to_remove: set[int] = set() for simplifier in [ get_double_items, get_double_colors, get_formatting_before_color, get_meaningless_resets_and_colors, get_end_non_text, ]: to_remove.update(simplifier(parsed)) return to_remove def squash_nearby_strings(parsed: _PARSED_MOTD_COMPONENTS_TYPEVAR) -> _PARSED_MOTD_COMPONENTS_TYPEVAR: """Squash duplicate strings together. Note that this function doesn't create a copy of passed array, it modifies it. This is what those typevars are for in the function signature. """ # in order to not break indexes, we need to fill values and then remove them after the loop fillers: set[int] = set() for index, item in enumerate(parsed): if not isinstance(item, str): continue try: next_item = parsed[index + 1] except IndexError: # Last item (without any next item) break if isinstance(next_item, str): parsed[index + 1] = ( # intentional mutation during iteration # ruff: ignore[loop-iterator-mutation] item + next_item ) fillers.add(index) for already_removed, index_to_remove in enumerate(fillers): _ = parsed.pop(index_to_remove - already_removed) return parsed def get_double_items(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Get indices of all doubled items that can be removed. Removes any items that are followed by an item of the same kind (compared using ``__eq__``). """ to_remove: set[int] = set() for index, item in enumerate(parsed): try: next_item = parsed[index + 1] except IndexError: # Last item (without any next item) break if isinstance(item, (AnyFormatting, AnyMinecraftColor, WebColor)) and item == next_item: to_remove.add(index) return to_remove def get_double_colors(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Get indices of all doubled color items. As colors (obviously) override each other, we only ever care about the last one, ignore the previous ones. (for example: specifying red color, then orange, then yellow, then some text will just result in yellow text) """ to_remove: set[int] = set() prev_color: int | None = None for index, item in enumerate(parsed): if isinstance(item, (AnyMinecraftColor, WebColor)): # If we found a color after another, remove the previous color if prev_color is not None: to_remove.add(prev_color) prev_color = index # If we find a string, that's what our color we found previously applies to, # set prev_color to None, marking this color as used if isinstance(item, str): prev_color = None return to_remove def get_formatting_before_color(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Obtain indices of all unused formatting items before colors. Colors override any formatting before them, meaning we only ever care about the color, and can ignore all formatting before it. (For example: specifying bold formatting, then italic, then yellow, will just result in yellow text.) """ to_remove: set[int] = set() collected_formattings: list[int] = [] for index, item in enumerate(parsed): # Collect the indices of formatting items if isinstance(item, AnyFormatting): collected_formattings.append(index) # Only run checks if we have some collected formatting items if len(collected_formattings) == 0: continue # If there's a string after some formatting, the formatting apply to it. # This means they're not unused, remove them. if isinstance(item, str) and not item.isspace(): collected_formattings = [] continue # If there's a color after some formatting, these formatting will be overridden # as colors reset everything. This makes these formatting pointless, mark them # for removal. if isinstance(item, (AnyMinecraftColor, WebColor)): to_remove.update(collected_formattings) collected_formattings = [] return to_remove def get_end_non_text(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Get indices of all trailing items, found after the last text component. Any color/formatting items only make sense when they apply to some text. If there are some at the end, after the last text, they're pointless and can be removed. """ to_remove: set[int] = set() for rev_index, item in enumerate(reversed(parsed)): # The moment we find our last string, stop the loop if isinstance(item, str): break # Remove any color/formatting that doesn't apply to text if isinstance(item, (AnyMinecraftColor, AnyFormatting, WebColor)): index = len(parsed) - 1 - rev_index to_remove.add(index) return to_remove def get_meaningless_resets_and_colors(parsed: Sequence[ParsedMotdComponent]) -> set[int]: to_remove: set[int] = set() active_color: AnyMinecraftColor | WebColor | None = None active_formatting: AnyFormatting | None = None for index, item in enumerate(parsed): if isinstance(item, (JavaMinecraftColor, BedrockMinecraftColor, WebColor)): if active_color == item: to_remove.add(index) active_color = item continue if isinstance(item, AnyFormatting): if item.name == "RESET": if active_color is None and active_formatting is None: to_remove.add(index) continue active_color, active_formatting = None, None continue if active_formatting == item: to_remove.add(index) active_formatting = item return to_remove py-mine-mcstatus-341e7a4/mcstatus/motd/_transformers.py000066400000000000000000000246471524603215100233430ustar00rootroot00000000000000from __future__ import annotations import abc import typing as t from mcstatus.motd.components import ( AnyFormatting, AnyMinecraftColor, BedrockFormatting, BedrockMinecraftColor, InvalidFormatting, JavaFormatting, JavaMinecraftColor, ParsedMotdComponent, TranslationTag, WebColor, ) if t.TYPE_CHECKING: from collections.abc import Sequence from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "AnsiTransformer", "HtmlTransformer", "MinecraftTransformer", "PlainTransformer", ] _HOOK_RETURN_TYPE = t.TypeVar("_HOOK_RETURN_TYPE") _END_RESULT_TYPE = t.TypeVar("_END_RESULT_TYPE") # MinecraftColor: (foreground, background) # ruff: ignore[commented-out-code] _SHARED_MINECRAFT_COLOR_TO_RGB = { "BLACK": ((0, 0, 0), (0, 0, 0)), "DARK_BLUE": ((0, 0, 170), (0, 0, 42)), "DARK_GREEN": ((0, 170, 0), (0, 42, 0)), "DARK_AQUA": ((0, 170, 170), (0, 42, 42)), "DARK_RED": ((170, 0, 0), (42, 0, 0)), "DARK_PURPLE": ((170, 0, 170), (42, 0, 42)), "GOLD": ((255, 170, 0), (64, 42, 0)), "GRAY": ((170, 170, 170), (42, 42, 42)), "DARK_GRAY": ((85, 85, 85), (21, 21, 21)), "BLUE": ((85, 85, 255), (21, 21, 63)), "GREEN": ((85, 255, 85), (21, 63, 21)), "AQUA": ((85, 255, 255), (21, 63, 63)), "RED": ((255, 85, 85), (63, 21, 21)), "LIGHT_PURPLE": ((255, 85, 255), (63, 21, 63)), "YELLOW": ((255, 255, 85), (63, 63, 21)), "WHITE": ((255, 255, 255), (63, 63, 63)), } _MINECRAFT_COLOR_TO_RGB_JAVA = _SHARED_MINECRAFT_COLOR_TO_RGB.copy() _MINECRAFT_COLOR_TO_RGB_JAVA["GRAY"] = ((170, 170, 170), (42, 42, 42)) _MINECRAFT_COLOR_TO_RGB_BEDROCK = _SHARED_MINECRAFT_COLOR_TO_RGB.copy() _MINECRAFT_COLOR_TO_RGB_BEDROCK.update( { "GRAY": ((198, 198, 198), (49, 49, 49)), "MINECOIN_GOLD": ((221, 214, 5), (55, 53, 1)), "MATERIAL_QUARTZ": ((227, 212, 209), (56, 53, 52)), "MATERIAL_IRON": ((206, 202, 202), (51, 50, 50)), "MATERIAL_NETHERITE": ((68, 58, 59), (17, 14, 14)), "MATERIAL_REDSTONE": ((151, 22, 7), (37, 5, 1)), "MATERIAL_COPPER": ((180, 104, 77), (45, 26, 19)), "MATERIAL_GOLD": ((222, 177, 45), (55, 44, 11)), "MATERIAL_EMERALD": ((17, 159, 54), (4, 40, 13)), "MATERIAL_DIAMOND": ((44, 186, 168), (11, 46, 42)), "MATERIAL_LAPIS": ((33, 73, 123), (8, 18, 30)), "MATERIAL_AMETHYST": ((154, 92, 198), (38, 23, 49)), "MATERIAL_RESIN": ((235, 114, 20), (59, 29, 5)), } ) class _BaseTransformer(abc.ABC, t.Generic[_HOOK_RETURN_TYPE, _END_RESULT_TYPE]): """Base MOTD transformer class. Transformers are responsible for providing a way to generate an alternative representation of MOTD, for example, as HTML. The methods ``_handle_*`` handle each :type:`~mcstatus.motd.components.ParsedMotdComponent` individually. """ def __init__(self, *, bedrock: bool) -> None: self.bedrock: bool = bedrock def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> _END_RESULT_TYPE: return self._format_output([handled for component in motd_components for handled in self._handle_component(component)]) @abc.abstractmethod def _format_output(self, results: list[_HOOK_RETURN_TYPE]) -> _END_RESULT_TYPE: ... def _handle_component( self, component: ParsedMotdComponent ) -> tuple[_HOOK_RETURN_TYPE, _HOOK_RETURN_TYPE] | tuple[_HOOK_RETURN_TYPE]: color_enum = BedrockMinecraftColor if self.bedrock else JavaMinecraftColor formatting_enum = BedrockFormatting if self.bedrock else JavaFormatting def handler(component: ParsedMotdComponent) -> _HOOK_RETURN_TYPE: if type(component) is color_enum: return self._handle_minecraft_color(component) if type(component) is WebColor: return self._handle_web_color(component) if type(component) is formatting_enum: return self._handle_formatting(component) if type(component) is InvalidFormatting: return self._handle_invalid_formatting(component) if type(component) is TranslationTag: return self._handle_translation_tag(component) if type(component) is str: return self._handle_str(component) raise RuntimeError(f"Invalid component type: {type(component)}: {component!r}") additional = None if isinstance(component, color_enum): additional = self._handle_formatting(formatting_enum.RESET) return (additional, handler(component)) if additional is not None else (handler(component),) @abc.abstractmethod def _handle_str(self, element: str, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_translation_tag(self, _: TranslationTag, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_web_color(self, element: WebColor, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_formatting(self, element: AnyFormatting, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_invalid_formatting(self, element: InvalidFormatting, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> _HOOK_RETURN_TYPE: ... class _NothingTransformer(_BaseTransformer[str, str]): """Transformer that transforms all elements into empty strings. This transformer acts as a base for other transformers with string result type. """ @override def _format_output(self, results: list[str]) -> str: return "".join(results) @override def _handle_str(self, _element: str, /) -> str: return "" @override def _handle_minecraft_color(self, _element: AnyMinecraftColor, /) -> str: return "" @override def _handle_web_color(self, _element: WebColor, /) -> str: return "" @override def _handle_formatting(self, _element: AnyFormatting, /) -> str: return "" @override def _handle_invalid_formatting(self, _element: InvalidFormatting, /) -> str: return "" @override def _handle_translation_tag(self, _element: TranslationTag, /) -> str: return "" class PlainTransformer(_NothingTransformer): @override def _handle_str(self, element: str, /) -> str: return element @t.final class MinecraftTransformer(PlainTransformer): @override def _handle_component(self, component: ParsedMotdComponent) -> tuple[str, str] | tuple[str]: result = super()._handle_component(component) if len(result) == 2: return (result[1],) return result @override def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str: return "§" + element.value @override def _handle_formatting(self, element: AnyFormatting, /) -> str: return "§" + element.value # future compatibility @override def _handle_invalid_formatting(self, element: InvalidFormatting, /) -> str: return "§" + element.value @t.final class HtmlTransformer(PlainTransformer): _FORMATTING_TO_HTML_TAGS: t.ClassVar = { "BOLD": "b", "STRIKETHROUGH": "s", "ITALIC": "i", "UNDERLINED": "u", } def __init__(self, *, bedrock: bool) -> None: super().__init__(bedrock=bedrock) self.on_reset: list[str] = [] @override def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str: self.on_reset = [] return super().transform(motd_components) @override def _format_output(self, results: list[str]) -> str: return "

" + super()._format_output(results) + "".join(self.on_reset) + "

" @override def _handle_str(self, element: str, /) -> str: return element.replace("\n", "
") @override def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str: color_map = _MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else _MINECRAFT_COLOR_TO_RGB_JAVA fg_color, bg_color = color_map[element.name] self.on_reset.append("") return f"" @override def _handle_web_color(self, element: WebColor, /) -> str: self.on_reset.append("") return f"" @override def _handle_formatting(self, element: AnyFormatting, /) -> str: if element.name == "RESET": to_return = "".join(self.on_reset) self.on_reset = [] return to_return if element.name == "OBFUSCATED": self.on_reset.append("") return "" tag_name = self._FORMATTING_TO_HTML_TAGS[element.name] self.on_reset.append(f"") return f"<{tag_name}>" @t.final class AnsiTransformer(PlainTransformer): _FORMATTING_TO_ANSI_TAGS: t.ClassVar = { "BOLD": "1", "STRIKETHROUGH": "9", "ITALIC": "3", "UNDERLINED": "4", "OBFUSCATED": "5", } _MINECRAFT_COLOR_TO_RGB_JAVA: t.ClassVar = { key: foreground for key, (foreground, _background) in _MINECRAFT_COLOR_TO_RGB_JAVA.items() } _MINECRAFT_COLOR_TO_RGB_BEDROCK: t.ClassVar = { key: foreground for key, (foreground, _background) in _MINECRAFT_COLOR_TO_RGB_BEDROCK.items() } def __init__(self, *, bedrock: bool) -> None: super().__init__(bedrock=bedrock) def ansi_color(self, color: tuple[int, int, int] | AnyMinecraftColor) -> str: """Transform RGB color to ANSI color code.""" if isinstance(color, AnyMinecraftColor): color_to_rgb = self._MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else self._MINECRAFT_COLOR_TO_RGB_JAVA color = color_to_rgb[color.name] return "\033[38;2;{};{};{}m".format(*color) @override def _format_output(self, results: list[str]) -> str: return "\033[0m" + super()._format_output(results) + "\033[0m" @override def _handle_minecraft_color(self, element: AnyMinecraftColor, /) -> str: return self.ansi_color(element) @override def _handle_web_color(self, element: WebColor, /) -> str: return self.ansi_color(element.rgb) @override def _handle_formatting(self, element: AnyFormatting, /) -> str: if element.name == "RESET": return "\033[0m" return "\033[" + self._FORMATTING_TO_ANSI_TAGS[element.name] + "m" py-mine-mcstatus-341e7a4/mcstatus/motd/components.py000066400000000000000000000126241524603215100226340ustar00rootroot00000000000000from __future__ import annotations import typing as t from dataclasses import dataclass from enum import Enum if t.TYPE_CHECKING: from typing_extensions import Self __all__ = [ "AnyFormatting", "AnyMinecraftColor", "BedrockFormatting", "BedrockMinecraftColor", "InvalidFormatting", "JavaFormatting", "JavaMinecraftColor", "ParsedMotdComponent", "TranslationTag", "WebColor", ] # NOTE: keep in sync with the definition in docs (`docs/api/motd_parsing.rst`) # the autodocs plugin does not support type aliases yet, so those have to be # defined manually in docs ParsedMotdComponent: t.TypeAlias = "AnyFormatting | AnyMinecraftColor | InvalidFormatting | WebColor | TranslationTag | str" @dataclass(frozen=True) class InvalidFormatting: """Unrecognized color formatting. This might be because the color returned is genuinely invalid, or because this is a new color which mcstatus doesn't yet recognize. """ value: str class JavaFormatting(Enum): """Enum for Java Formatting codes. See `Minecraft wiki `__ for more info. """ BOLD = "l" ITALIC = "o" UNDERLINED = "n" STRIKETHROUGH = "m" OBFUSCATED = "k" RESET = "r" class BedrockFormatting(Enum): """Enum for Bedrock Formatting codes. The same as :class:`.JavaFormatting`, but excludes :attr:`JavaFormatting.UNDERLINED` and :attr:`JavaFormatting.STRIKETHROUGH`, as they don't work on Bedrock (see `MCPE-41729 `_) See `Minecraft wiki `__ for more info. """ BOLD = "l" ITALIC = "o" OBFUSCATED = "k" RESET = "r" class JavaMinecraftColor(Enum): """Enum for Java color codes. See `Minecraft wiki `_ for more info. """ BLACK = "0" DARK_BLUE = "1" DARK_GREEN = "2" DARK_AQUA = "3" DARK_RED = "4" DARK_PURPLE = "5" GOLD = "6" GRAY = "7" DARK_GRAY = "8" BLUE = "9" GREEN = "a" AQUA = "b" RED = "c" LIGHT_PURPLE = "d" YELLOW = "e" WHITE = "f" class BedrockMinecraftColor(Enum): """Enum for Bedrock color codes. See `Minecraft wiki `_ for more info. """ BLACK = "0" DARK_BLUE = "1" DARK_GREEN = "2" DARK_AQUA = "3" DARK_RED = "4" DARK_PURPLE = "5" GOLD = "6" GRAY = "7" DARK_GRAY = "8" BLUE = "9" GREEN = "a" AQUA = "b" RED = "c" LIGHT_PURPLE = "d" YELLOW = "e" WHITE = "f" MINECOIN_GOLD = "g" MATERIAL_QUARTZ = "h" MATERIAL_IRON = "i" MATERIAL_NETHERITE = "j" MATERIAL_REDSTONE = "m" MATERIAL_COPPER = "n" MATERIAL_GOLD = "p" MATERIAL_EMERALD = "q" MATERIAL_DIAMOND = "s" MATERIAL_LAPIS = "t" MATERIAL_AMETHYST = "u" MATERIAL_RESIN = "v" # having these as unions, means that we can use `isinstance` AnyFormatting: t.TypeAlias = JavaFormatting | BedrockFormatting AnyMinecraftColor: t.TypeAlias = JavaMinecraftColor | BedrockMinecraftColor @dataclass(frozen=True) class WebColor: """Raw HTML color from MOTD. Can be found in MOTD when someone uses gradient. .. note:: Actually supported in Minecraft 1.16+ only. """ hex: str rgb: tuple[int, int, int] @classmethod def from_hex(cls, hex: str) -> Self: # ruff: ignore[builtin-argument-shadowing] """Construct web color using hex color string. :raises ValueError: Invalid hex color string. :returns: New :class:`WebColor` instance. """ hex = hex.lstrip("#") # ruff: ignore[builtin-variable-shadowing] if len(hex) not in {3, 6}: raise ValueError(f"Got too long/short hex color: {'#' + hex!r}") if len(hex) == 3: hex = "{0}{0}{1}{1}{2}{2}".format(*hex) # ruff: ignore[builtin-variable-shadowing] try: rgb = t.cast("tuple[int, int, int]", tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4))) except ValueError as e: raise ValueError(f"Failed to parse given hex color: {'#' + hex!r}") from e return cls.from_rgb(rgb) @classmethod def from_rgb(cls, rgb: tuple[int, int, int]) -> Self: """Construct web color using rgb color tuple. :raises ValueError: When RGB color is out of its 8-bit range. :returns: New :class:`WebColor` instance. """ cls._check_rgb(rgb) hex = "#{:02x}{:02x}{:02x}".format(*rgb) # ruff: ignore[builtin-variable-shadowing] return cls(hex, rgb) @staticmethod def _check_rgb(rgb: tuple[int, int, int]) -> None: index_to_color_name = {0: "red", 1: "green", 2: "blue"} for index, value in enumerate(rgb): if not 255 >= value >= 0: color_name = index_to_color_name[index] raise ValueError(f"RGB color byte out of its 8-bit range (0-255) for {color_name} ({value=})") @dataclass(frozen=True) class TranslationTag: """Represents a ``translate`` field in server's answer. This just exists, but is completely ignored by our transformers. You can find translation tags in :attr:`Motd.parsed ` attribute. .. seealso:: `Minecraft's wiki. `__ """ id: str py-mine-mcstatus-341e7a4/mcstatus/py.typed000066400000000000000000000000001524603215100206120ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/responses/000077500000000000000000000000001524603215100211465ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/mcstatus/responses/__init__.py000066400000000000000000000020321524603215100232540ustar00rootroot00000000000000from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion from mcstatus.responses.bedrock import BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion from mcstatus.responses.forge import ForgeData, ForgeDataChannel, ForgeDataMod from mcstatus.responses.java import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion from mcstatus.responses.query import QueryPlayers, QueryResponse, QuerySoftware __all__ = [ "BaseStatusPlayers", "BaseStatusResponse", "BaseStatusVersion", "BedrockStatusPlayers", "BedrockStatusResponse", "BedrockStatusVersion", "ForgeData", "ForgeDataChannel", "ForgeDataMod", "JavaStatusPlayer", "JavaStatusPlayers", "JavaStatusResponse", "JavaStatusVersion", "LegacyStatusPlayers", "LegacyStatusResponse", "LegacyStatusVersion", "QueryPlayers", "QueryResponse", "QuerySoftware", ] py-mine-mcstatus-341e7a4/mcstatus/responses/_raw.py000066400000000000000000000046211524603215100224530ustar00rootroot00000000000000from __future__ import annotations from typing import Literal, TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: from typing_extensions import NotRequired __all__ = [ "RawForgeData", "RawForgeDataChannel", "RawForgeDataMod", "RawJavaResponse", "RawJavaResponseMotd", "RawJavaResponseMotdWhenDict", "RawJavaResponsePlayer", "RawJavaResponsePlayers", "RawJavaResponseVersion", "RawQueryResponse", ] RawJavaResponseMotd: TypeAlias = "RawJavaResponseMotdWhenDict | list[RawJavaResponseMotdWhenDict | str] | str" class RawForgeDataChannel(TypedDict): res: str """Channel name and ID (for example ``fml:handshake``).""" version: str """Channel version (for example ``1.2.3.4``).""" required: bool """Is this channel required for client to join?""" class RawForgeDataMod(TypedDict, total=False): modid: str modId: str modmarker: str """Mod version.""" version: str class RawForgeData(TypedDict, total=False): fmlNetworkVersion: int channels: list[RawForgeDataChannel] mods: list[RawForgeDataMod] modList: list[RawForgeDataMod] d: str truncated: bool class RawJavaResponsePlayer(TypedDict): name: str id: str class RawJavaResponsePlayers(TypedDict): online: int max: int sample: NotRequired[list[RawJavaResponsePlayer] | None] class RawJavaResponseVersion(TypedDict): name: str protocol: int class RawJavaResponseMotdWhenDict(TypedDict, total=False): text: str # only present if `translate` is set translate: str # same to the above field extra: list[RawJavaResponseMotdWhenDict | str] color: str bold: bool strikethrough: bool italic: bool underlined: bool obfuscated: bool class RawJavaResponse(TypedDict): description: RawJavaResponseMotd players: RawJavaResponsePlayers version: RawJavaResponseVersion favicon: NotRequired[str] forgeData: NotRequired[RawForgeData | None] modinfo: NotRequired[RawForgeData | None] isModded: NotRequired[bool] enforcesSecureChat: NotRequired[bool] class RawQueryResponse(TypedDict): hostname: str gametype: Literal["SMP"] game_id: Literal["MINECRAFT"] version: str plugins: str map: str numplayers: str # can be transformed into `int` maxplayers: str # can be transformed into `int` hostport: str # can be transformed into `int` hostip: str py-mine-mcstatus-341e7a4/mcstatus/responses/base.py000066400000000000000000000056401524603215100224370ustar00rootroot00000000000000from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import asdict, dataclass from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from typing_extensions import Self from mcstatus.motd import Motd __all__ = [ "BaseStatusPlayers", "BaseStatusResponse", "BaseStatusVersion", ] @dataclass(frozen=True) class BaseStatusResponse(ABC): """Class for storing shared data from a status response.""" players: BaseStatusPlayers """The players information.""" version: BaseStatusVersion """The version information.""" motd: Motd """Message Of The Day. Also known as description. .. seealso:: :doc:`/api/motd_parsing`. """ latency: float """Latency between a server and the client (you). In milliseconds.""" @property def description(self) -> str: """Alias to the :meth:`mcstatus.motd.Motd.to_minecraft` method.""" return self.motd.to_minecraft() @classmethod @abstractmethod def build(cls, *args: Any, **kwargs: Any) -> Self: """Build BaseStatusResponse and check is it valid. :param args: Arguments in specific realisation. :param kwargs: Keyword arguments in specific realisation. :return: :class:`BaseStatusResponse` object. """ raise NotImplementedError("You can't use abstract methods.") def as_dict(self) -> dict[str, Any]: """Return the dataclass as JSON-serializable :class:`dict`. Do note that this method doesn't return :class:`string ` but :class:`dict`, so you can do some processing on returned value. Difference from :attr:`~mcstatus.responses.JavaStatusResponse.raw` is in that, :attr:`~mcstatus.responses.JavaStatusResponse.raw` returns raw response in the same format as we got it. This method returns the response in a more user-friendly JSON serializable format (for example, :attr:`~mcstatus.responses.BaseStatusResponse.motd` is returned as a :func:`Minecraft string ` and not :class:`dict`). """ as_dict = asdict(self) as_dict["motd"] = self.motd.simplify().to_minecraft() return as_dict @dataclass(frozen=True) class BaseStatusPlayers(ABC): """Class for storing information about players on the server.""" online: int """Current number of online players.""" max: int """The maximum allowed number of players (aka server slots).""" @dataclass(frozen=True) class BaseStatusVersion(ABC): """A class for storing version information.""" name: str """The version name, like ``1.19.3``. See `Minecraft wiki `__ for complete list. """ protocol: int """The protocol version, like ``761``. See `Minecraft wiki `__. """ py-mine-mcstatus-341e7a4/mcstatus/responses/bedrock.py000066400000000000000000000046001524603215100231310ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Any, TYPE_CHECKING from mcstatus.motd import Motd from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion if TYPE_CHECKING: from typing_extensions import Self, override else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "BedrockStatusPlayers", "BedrockStatusResponse", "BedrockStatusVersion", ] @dataclass(frozen=True) class BedrockStatusResponse(BaseStatusResponse): """The response object for :meth:`BedrockServer.status() `.""" players: BedrockStatusPlayers version: BedrockStatusVersion map_name: str | None """The name of the map.""" gamemode: str | None """The name of the gamemode on the server.""" @override @classmethod def build(cls, decoded_data: list[Any], latency: float) -> Self: """Build BaseStatusResponse and check is it valid. :param decoded_data: Raw decoded response object. :param latency: Latency of the request. :return: :class:`BedrockStatusResponse` object. """ try: map_name = decoded_data[7] except IndexError: map_name = None try: gamemode = decoded_data[8] except IndexError: gamemode = None return cls( players=BedrockStatusPlayers( online=int(decoded_data[4]), max=int(decoded_data[5]), ), version=BedrockStatusVersion( name=decoded_data[3], protocol=int(decoded_data[2]), brand=decoded_data[0], ), motd=Motd.parse(decoded_data[1], bedrock=True), latency=latency, map_name=map_name, gamemode=gamemode, ) @dataclass(frozen=True) class BedrockStatusPlayers(BaseStatusPlayers): """Class for storing information about players on the server.""" @dataclass(frozen=True) class BedrockStatusVersion(BaseStatusVersion): """A class for storing version information.""" name: str """The version name, like ``1.19.60``. See `Minecraft wiki `__ for complete list. """ brand: str """``MCPE`` or ``MCEE`` for Education Edition.""" py-mine-mcstatus-341e7a4/mcstatus/responses/forge.py000066400000000000000000000215131524603215100226240ustar00rootroot00000000000000"""Decoder for data from Forge, that is included into a response object. After 1.18.1, Forge started to compress its mod data into a UTF-16 string that represents binary data containing data like the forge mod loader network version, a big list of channels that all the forge mods use, and a list of mods the server has. Before 1.18.1, the mod data was in `forgeData` attribute inside a response object. We support this implementation too. For more information see this file from forge itself: https://github.com/MinecraftForge/MinecraftForge/blob/54b08d2711a15418130694342a3fe9a5dfe005d2/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73 """ from __future__ import annotations from dataclasses import dataclass from io import StringIO from typing import Final, TYPE_CHECKING, final from mcstatus._protocol.io.base_io import BaseSyncReader, StructFormat from mcstatus._protocol.io.buffer import Buffer from mcstatus._utils import or_none if TYPE_CHECKING: from typing_extensions import Self, override from mcstatus.responses._raw import RawForgeData, RawForgeDataChannel, RawForgeDataMod else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "ForgeData", "ForgeDataChannel", "ForgeDataMod", ] _VERSION_FLAG_IGNORE_SERVER_ONLY: Final = 0b1 _IGNORE_SERVER_ONLY: Final = "" @dataclass(frozen=True) class ForgeDataChannel: """A single Forge data channel.""" name: str """Channel name and ID (for example ``fml:handshake``).""" version: str """Channel version (for example ``1.2.3.4``).""" required: bool """Is this channel required for client to join?""" @classmethod def build(cls, raw: RawForgeDataChannel) -> Self: """Build an object about Forge channel from raw response. :param raw: ``channel`` element in raw forge response :class:`dict`. :return: :class:`ForgeDataChannel` object. """ return cls(name=raw["res"], version=raw["version"], required=raw["required"]) @classmethod def decode(cls, buffer: Buffer, mod_id: str | None = None) -> Self: """Decode an object about Forge channel from decoded optimized buffer. :param buffer: :class:`Buffer` object from UTF-16 encoded binary data. :param mod_id: Optional mod id prefix :class:`str`. :return: :class:`ForgeDataChannel` object. """ channel_identifier = buffer.read_utf() if mod_id is not None: channel_identifier = f"{mod_id}:{channel_identifier}" version = buffer.read_utf() client_required = buffer.read_value(StructFormat.BOOL) return cls( name=channel_identifier, version=version, required=client_required, ) @dataclass(frozen=True) class ForgeDataMod: """A single Forge mod.""" name: str """A mod name.""" marker: str """A mod marker. Usually a version.""" @classmethod def build(cls, raw: RawForgeDataMod) -> Self: """Build an object about Forge mod from raw response. :param raw: ``mod`` element in raw forge response :class:`dict`. :return: :class:`ForgeDataMod` object. """ # In FML v1, modmarker was version instead. mod_version = or_none(raw.get("modmarker"), raw.get("version")) if mod_version is None: raise KeyError(f"Mod version in Forge mod data must be provided. Mod info: {raw}") # In FML v2, modid was modId instead. At least one of the two should exist. mod_id = or_none(raw.get("modid"), raw.get("modId")) if mod_id is None: raise KeyError(f"Mod ID in Forge mod data must be provided. Mod info: {raw}.") return cls(name=mod_id, marker=mod_version) @classmethod def decode(cls, buffer: Buffer) -> tuple[Self, list[ForgeDataChannel]]: """Decode data about a Forge mod from decoded optimized buffer. :param buffer: :class:`Buffer` object from UTF-16 encoded binary data. :return: :class:`tuple` object of :class:`ForgeDataMod` object and :class:`list` of :class:`ForgeDataChannel` objects. """ channel_version_flags = buffer.read_varint() channel_count = channel_version_flags >> 1 is_server = channel_version_flags & _VERSION_FLAG_IGNORE_SERVER_ONLY != 0 mod_id = buffer.read_utf() mod_version = _IGNORE_SERVER_ONLY if not is_server: mod_version = buffer.read_utf() channels = [ForgeDataChannel.decode(buffer, mod_id) for _ in range(channel_count)] return cls(name=mod_id, marker=mod_version), channels @final class _StringBuffer(BaseSyncReader): """String Buffer for reading utf-16 encoded binary data.""" __slots__ = ("received", "stringio") def __init__(self, stringio: StringIO) -> None: self.stringio = stringio self.received = bytearray() @override def read(self, length: int, /) -> bytes: """Read length bytes from ``self``, and return a byte array.""" data = bytearray() while self.received and len(data) < length: data.append(self.received.pop(0)) while len(data) < length: result = self.stringio.read(1) if not result: raise OSError(f"Not enough data to read! {len(data)} < {length}") data.extend(result.encode("utf-16be")) while len(data) > length: self.received.append(data.pop()) return bytes(data) def remaining(self) -> int: """Return number of reads remaining.""" return len(self.stringio.getvalue()) - self.stringio.tell() + len(self.received) def read_optimized_size(self) -> int: """Read encoded data length.""" return self.read_value(StructFormat.SHORT) | (self.read_value(StructFormat.SHORT) << 15) def read_optimized_buffer(self) -> Buffer: """Read encoded buffer.""" size = self.read_optimized_size() buffer = Buffer() value, bits = 0, 0 while buffer.remaining < size: if bits < 8 and self.remaining(): # Ignoring sign bit value |= (self.read_value(StructFormat.SHORT) & 0x7FFF) << bits bits += 15 buffer.write((value & 0xFF).to_bytes(1, "big")) value >>= 8 bits -= 8 return buffer @dataclass(frozen=True) class ForgeData: """Class for storing information about Forge mods.""" fml_network_version: int """Forge Mod Loader network version.""" channels: list[ForgeDataChannel] """List of channels, both for mods and non-mods.""" mods: list[ForgeDataMod] """List of mods.""" truncated: bool """Is the mods list and or channel list incomplete?""" @staticmethod def _decode_optimized(string: str) -> Buffer: """Decode buffer from UTF-16 optimized binary data ``string``.""" with StringIO(string) as text: str_buffer = _StringBuffer(text) return str_buffer.read_optimized_buffer() @classmethod def build(cls, raw: RawForgeData) -> Self: """Build an object about Forge mods from raw response. :param raw: ``forgeData`` attribute in raw response :class:`dict`. :return: :class:`ForgeData` object. """ fml_network_version = raw.get("fmlNetworkVersion", 1) # see: # https://github.com/MinecraftForge/MinecraftForge/blob/7d0330eb08299935714e34ac651a293e2609aa86/src/main/java/net/minecraftforge/network/ServerStatusPing.java#L27-L73 if "d" not in raw: mod_list = raw.get("mods") or raw.get("modList") if mod_list is None: raise KeyError("Neither `mods` or `modList` keys exist.") return cls( fml_network_version=fml_network_version, channels=[ForgeDataChannel.build(channel) for channel in raw.get("channels", ())], mods=[ForgeDataMod.build(mod) for mod in mod_list], truncated=False, ) buffer = cls._decode_optimized(raw["d"]) channels: list[ForgeDataChannel] = [] mods: list[ForgeDataMod] = [] truncated = buffer.read_value(StructFormat.BOOL) mod_count = buffer.read_value(StructFormat.USHORT) try: for _ in range(mod_count): mod, mod_channels = ForgeDataMod.decode(buffer) channels.extend(mod_channels) mods.append(mod) non_mod_channel_count = buffer.read_varint() channels.extend(ForgeDataChannel.decode(buffer) for _ in range(non_mod_channel_count)) except OSError: if not truncated: raise # If answer wasn't truncated, we lost some data on the way return cls( fml_network_version=fml_network_version, channels=channels, mods=mods, truncated=truncated, ) py-mine-mcstatus-341e7a4/mcstatus/responses/java.py000066400000000000000000000144161524603215100224470ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from mcstatus.motd import Motd from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion from mcstatus.responses.forge import ForgeData if TYPE_CHECKING: from typing_extensions import Self, override from mcstatus.responses._raw import RawJavaResponse, RawJavaResponsePlayer, RawJavaResponsePlayers, RawJavaResponseVersion else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "JavaStatusPlayer", "JavaStatusPlayers", "JavaStatusResponse", "JavaStatusVersion", ] @dataclass(frozen=True) class JavaStatusResponse(BaseStatusResponse): """The response object for :meth:`JavaServer.status() `.""" raw: RawJavaResponse """Raw response from the server. This is :class:`~typing.TypedDict` actually, please see sources to find what is here. """ players: JavaStatusPlayers version: JavaStatusVersion enforces_secure_chat: bool | None """Whether the server enforces secure chat (every message is signed up with a key). .. seealso:: `Signed Chat explanation `_, `22w17a changelog, where this was added `_. .. versionadded:: 11.1.0 """ icon: str | None """The icon of the server. In `Base64 `_ encoded PNG image format. .. seealso:: :ref:`pages/faq:how to get server image?` """ forge_data: ForgeData | None """Forge mod data (mod list, channels, etc). Only present if this is a forge (modded) server.""" is_modded: bool """Does the server advertise itself as modded. Special attribute sent by NeoForge or when ``forge_data`` is available. .. versionadded:: 14.2.0 """ @override @classmethod def build(cls, raw: RawJavaResponse, latency: float = 0) -> Self: """Build JavaStatusResponse and check is it valid. :param raw: Raw response :class:`dict`. :param latency: Time that server took to response (in milliseconds). :raise ValueError: If the required keys (``players``, ``version``, ``description``) are not present. :raise TypeError: If the required keys (``players`` - :class:`dict`, ``version`` - :class:`dict`, ``description`` - :class:`str`) are not of the expected type. :return: :class:`JavaStatusResponse` object. """ forge_data: ForgeData | None = None if raw_forge := raw.get("forgeData") or raw.get("modinfo"): forge_data = ForgeData.build(raw_forge) return cls( raw=raw, players=JavaStatusPlayers.build(raw["players"]), version=JavaStatusVersion.build(raw["version"]), motd=Motd.parse(raw.get("description", ""), bedrock=False), enforces_secure_chat=raw.get("enforcesSecureChat"), icon=raw.get("favicon"), latency=latency, forge_data=forge_data, is_modded=bool(forge_data or raw.get("isModded", False)), ) @dataclass(frozen=True) class JavaStatusPlayers(BaseStatusPlayers): """Class for storing information about players on the server.""" sample: list[JavaStatusPlayer] | None """List of players, who are online. If server didn't provide this, it will be :obj:`None`. Actually, this is what appears when you hover over the slot count on the multiplayer screen. .. note:: It's often empty or even contains some advertisement, because the specific server implementations or plugins can disable providing this information or even change it to something custom. There is nothing that ``mcstatus`` can to do here if the player sample was modified/disabled like this. """ @classmethod def build(cls, raw: RawJavaResponsePlayers) -> Self: """Build :class:`JavaStatusPlayers` from raw response :class:`dict`. :param raw: Raw response :class:`dict`. :raise ValueError: If the required keys (``online``, ``max``) are not present. :raise TypeError: If the required keys (``online`` - :class:`int`, ``max`` - :class:`int`, ``sample`` - :class:`list`) are not of the expected type. :return: :class:`JavaStatusPlayers` object. """ sample: list[JavaStatusPlayer] | None = None if (sample_raw := raw.get("sample")) is not None: sample = [JavaStatusPlayer.build(player) for player in sample_raw] return cls( online=raw["online"], max=raw["max"], sample=sample, ) @dataclass(frozen=True) class JavaStatusPlayer: """Class with information about a single player.""" name: str """Name of the player.""" id: str """ID of the player (in `UUID `_ format).""" @property def uuid(self) -> str: """Alias to :attr:`.id` field.""" return self.id @classmethod def build(cls, raw: RawJavaResponsePlayer) -> Self: """Build :class:`JavaStatusPlayer` from raw response :class:`dict`. :param raw: Raw response :class:`dict`. :raise ValueError: If the required keys (``name``, ``id``) are not present. :raise TypeError: If the required keys (``name`` - :class:`str`, ``id`` - :class:`str`) are not of the expected type. :return: :class:`JavaStatusPlayer` object. """ return cls(name=raw["name"], id=raw["id"]) @dataclass(frozen=True) class JavaStatusVersion(BaseStatusVersion): """A class for storing version information.""" @classmethod def build(cls, raw: RawJavaResponseVersion) -> Self: """Build :class:`JavaStatusVersion` from raw response dict. :param raw: Raw response :class:`dict`. :raise ValueError: If the required keys (``name``, ``protocol``) are not present. :raise TypeError: If the required keys (``name`` - :class:`str`, ``protocol`` - :class:`int`) are not of the expected type. :return: :class:`JavaStatusVersion` object. """ return cls(name=raw["name"], protocol=raw["protocol"]) py-mine-mcstatus-341e7a4/mcstatus/responses/legacy.py000066400000000000000000000043761524603215100227760ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from mcstatus.motd import Motd from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion if TYPE_CHECKING: from typing_extensions import Self, override else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = [ "LegacyStatusPlayers", "LegacyStatusResponse", "LegacyStatusVersion", ] @dataclass(frozen=True) class LegacyStatusResponse(BaseStatusResponse): """The response object for :meth:`LegacyServerStatus.status() `.""" players: LegacyStatusPlayers version: LegacyStatusVersion """The version information, only populates for servers >=12w42b (1.4 onwards).""" @override @classmethod def build(cls, decoded_data: list[str], latency: float) -> Self: """Build BaseStatusResponse and check is it valid. :param decoded_data: Raw decoded response object. :param latency: Latency of the request. :return: :class:`LegacyStatusResponse` object. """ return cls( players=LegacyStatusPlayers( online=int(decoded_data[3]), max=int(decoded_data[4]), ), version=LegacyStatusVersion( name=decoded_data[1], protocol=int(decoded_data[0]), ), motd=Motd.parse(decoded_data[2]), latency=latency, ) @dataclass(frozen=True) class LegacyStatusPlayers(BaseStatusPlayers): """Class for storing information about players on the server.""" @dataclass(frozen=True) class LegacyStatusVersion(BaseStatusVersion): """A class for storing version information.""" name: str """The version name, like ``1.19.3``. See `Minecraft wiki `__ for complete list. Will be ``<1.4`` for older releases, as those did not send version information. """ protocol: int """The protocol version, like ``761``. See `Minecraft wiki `__. ``-1`` means 1.3 and lower, before 1.4 servers did not send information about its version. """ py-mine-mcstatus-341e7a4/mcstatus/responses/query.py000066400000000000000000000110021524603215100226570ustar00rootroot00000000000000from __future__ import annotations from dataclasses import asdict, dataclass from typing import Any, TYPE_CHECKING from mcstatus.motd import Motd if TYPE_CHECKING: from typing_extensions import Self from mcstatus.responses._raw import RawQueryResponse __all__ = [ "QueryPlayers", "QueryResponse", "QuerySoftware", ] @dataclass(frozen=True) class QueryResponse: """The response object for :meth:`JavaServer.query() `.""" raw: RawQueryResponse """Raw response from the server. This is :class:`~typing.TypedDict` actually, please see sources to find what is here. """ motd: Motd """The MOTD of the server. Also known as description. .. seealso:: :doc:`/api/motd_parsing`. """ map_name: str """The name of the map. Default is ``world``.""" players: QueryPlayers """The players information.""" software: QuerySoftware """The software information.""" ip: str """The IP address the server is listening/was contacted on.""" port: int """The port the server is listening/was contacted on.""" game_type: str = "SMP" """The game type of the server. Hardcoded to ``SMP`` (survival multiplayer).""" game_id: str = "MINECRAFT" """The game ID of the server. Hardcoded to ``MINECRAFT``.""" @classmethod def build(cls, raw: RawQueryResponse, players_list: list[str]) -> Self: return cls( raw=raw, motd=Motd.parse(raw["hostname"], bedrock=False), map_name=raw["map"], players=QueryPlayers.build(raw, players_list), software=QuerySoftware.build(raw["version"], raw["plugins"]), ip=raw["hostip"], port=int(raw["hostport"]), game_type=raw["gametype"], game_id=raw["game_id"], ) def as_dict(self) -> dict[str, Any]: """Return the dataclass as JSON-serializable :class:`dict`. Do note that this method doesn't return :class:`string ` but :class:`dict`, so you can do some processing on returned value. Difference from :attr:`~mcstatus.responses.JavaStatusResponse.raw` is in that, :attr:`~mcstatus.responses.JavaStatusResponse.raw` returns raw response in the same format as we got it. This method returns the response in a more user-friendly JSON serializable format (for example, :attr:`~mcstatus.responses.BaseStatusResponse.motd` is returned as a :func:`Minecraft string ` and not :class:`dict`). """ as_dict = asdict(self) as_dict["motd"] = self.motd.simplify().to_minecraft() as_dict["players"] = asdict(self.players) as_dict["software"] = asdict(self.software) return as_dict @dataclass(frozen=True) class QueryPlayers: """Class for storing information about players on the server.""" online: int """The number of online players.""" max: int """The maximum allowed number of players (server slots).""" list: list[str] """The list of online players.""" @classmethod def build(cls, raw: RawQueryResponse, players_list: list[str]) -> Self: return cls( online=int(raw["numplayers"]), max=int(raw["maxplayers"]), list=players_list, ) @dataclass(frozen=True) class QuerySoftware: """Class for storing information about software on the server.""" version: str """The version of the software.""" brand: str """The brand of the software. Like `Paper `_ or `Spigot `_.""" plugins: list[str] """The list of plugins. Can be an empty list if hidden.""" @classmethod def build(cls, version: str, plugins: str) -> Self: brand, parsed_plugins = cls._parse_plugins(plugins) return cls( version=version, brand=brand, plugins=parsed_plugins, ) @staticmethod def _parse_plugins(plugins: str) -> tuple[str, list[str]]: """Parse plugins string to list. Returns: :class:`tuple` with two elements. First is brand of server (:attr:`.brand`) and second is a list of :attr:`plugins`. """ brand = "vanilla" parsed_plugins = [] if plugins: parts = plugins.split(":", 1) brand = parts[0].strip() if len(parts) == 2: parsed_plugins = [s.strip() for s in parts[1].split(";")] return brand, parsed_plugins py-mine-mcstatus-341e7a4/mcstatus/server.py000066400000000000000000000344121524603215100210110ustar00rootroot00000000000000from __future__ import annotations from abc import ABC from typing import ClassVar, TYPE_CHECKING, final from mcstatus._net.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup from mcstatus._protocol.bedrock_client import BedrockClient from mcstatus._protocol.io.connection import ( TCPAsyncSocketConnection, TCPSocketConnection, UDPAsyncSocketConnection, UDPSocketConnection, ) from mcstatus._protocol.java_client import AsyncJavaClient, JavaClient from mcstatus._protocol.legacy_client import AsyncLegacyClient, LegacyClient from mcstatus._protocol.query_client import AsyncQueryClient, QueryClient from mcstatus._utils import retry if TYPE_CHECKING: from typing_extensions import Self, override from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse else: override = lambda f: f # ruff: ignore[lambda-assignment] __all__ = ["BedrockServer", "JavaServer", "LegacyServer", "MCServer"] class MCServer(ABC): """Base abstract class for a general minecraft server. This class only contains the basic logic shared across both java and bedrock versions, it doesn't include any version specific settings and it can't be used to make any requests. """ DEFAULT_PORT: ClassVar[int] def __init__(self, host: str, port: int | None = None, timeout: float = 3) -> None: """ :param host: The host/ip of the minecraft server. :param port: The port that the server is on. :param timeout: The timeout in seconds before failing to connect. """ # ruff: ignore[missing-blank-line-after-summary, multi-line-summary-first-line] if port is None: port = self.DEFAULT_PORT self.address: Address = Address(host, port) self.timeout: float = timeout @classmethod def lookup(cls, address: str, timeout: float = 3) -> Self: """Mimics minecraft's server address field. :param address: The address of the Minecraft server, like ``example.com:19132`` :param timeout: The timeout in seconds before failing to connect. """ addr = Address.parse_address(address, default_port=cls.DEFAULT_PORT) return cls(addr.host, addr.port, timeout=timeout) class BaseJavaServer(MCServer): """Base class for a Minecraft Java Edition server. .. versionadded:: 12.1.0 """ DEFAULT_PORT: ClassVar[int] = 25565 @override @classmethod def lookup(cls, address: str, timeout: float = 3) -> Self: """Mimics minecraft's server address field. With Java servers, on top of just parsing the address, we also check the DNS records for an SRV record that points to the server, which is the same behavior as with minecraft's server address field for Java. This DNS record resolution is happening synchronously (see :meth:`.async_lookup`). :param address: The address of the Minecraft server, like ``example.com:25565``. :param timeout: The timeout in seconds before failing to connect. """ addr = minecraft_srv_address_lookup(address, default_port=cls.DEFAULT_PORT, lifetime=timeout) return cls(addr.host, addr.port, timeout=timeout) @classmethod async def async_lookup(cls, address: str, timeout: float = 3) -> Self: """Asynchronous alternative to :meth:`.lookup`. For more details, check the :meth:`JavaServer.lookup() <.lookup>` docstring. """ addr = await async_minecraft_srv_address_lookup(address, default_port=cls.DEFAULT_PORT, lifetime=timeout) return cls(addr.host, addr.port, timeout=timeout) @final class JavaServer(BaseJavaServer): """Base class for a 1.7+ Minecraft Java Edition server.""" def __init__(self, host: str, port: int | None = None, timeout: float = 3, query_port: int | None = None) -> None: """ :param host: The host/ip of the minecraft server. :param port: The port that the server is on. :param timeout: The timeout in seconds before failing to connect. :param query_port: Typically the same as ``port`` but can be different. """ # ruff: ignore[missing-blank-line-after-summary, multi-line-summary-first-line] super().__init__(host, port, timeout) if query_port is None: query_port = port or self.DEFAULT_PORT self.query_port = query_port _ = Address(host, self.query_port) # Ensure query_port is valid def ping(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> float: """Check the latency between a Minecraft Java Edition server and the client (you). Note that most non-vanilla implementations fail to respond to a ping packet unless a status packet is sent first. Expect ``OSError: Server did not respond with any information!`` in those cases. The workaround is to use the latency provided with :meth:`.status` as ping time. :param tries: The number of times to retry if an error is encountered. :param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions. :param ping_token: Token of the packet, default is a random number. :return: The latency between the Minecraft Server and you. """ with TCPSocketConnection(self.address, self.timeout) as connection: return self._retry_ping(connection, tries=tries, version=version, ping_token=ping_token) @retry(tries=3) def _retry_ping( self, connection: TCPSocketConnection, *, tries: int = 3, # ruff: ignore[unused-method-argument] version: int, ping_token: int | None, ) -> float: java_client = JavaClient( connection, address=self.address, version=version, ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int ) java_client.handshake() return java_client.test_ping() async def async_ping(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> float: """Asynchronously check the latency between a Minecraft Java Edition server and the client (you). Note that most non-vanilla implementations fail to respond to a ping packet unless a status packet is sent first. Expect ``OSError: Server did not respond with any information!`` in those cases. The workaround is to use the latency provided with :meth:`.async_status` as ping time. :param tries: The number of times to retry if an error is encountered. :param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions. :param ping_token: Token of the packet, default is a random number. :return: The latency between the Minecraft Server and you. """ async with TCPAsyncSocketConnection(self.address, self.timeout) as connection: return await self._retry_async_ping(connection, tries=tries, version=version, ping_token=ping_token) @retry(tries=3) async def _retry_async_ping( self, connection: TCPAsyncSocketConnection, *, tries: int = 3, # ruff: ignore[unused-method-argument] version: int, ping_token: int | None, ) -> float: java_client = AsyncJavaClient( connection, address=self.address, version=version, ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int ) await java_client.handshake() ping = await java_client.test_ping() return ping def status(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> JavaStatusResponse: """Check the status of a Minecraft Java Edition server via the status protocol. :param tries: The number of times to retry if an error is encountered. :param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions. :param ping_token: Token of the packet, default is a random number. :return: Status information in a :class:`~mcstatus.responses.JavaStatusResponse` instance. """ with TCPSocketConnection(self.address, self.timeout) as connection: return self._retry_status(connection, tries=tries, version=version, ping_token=ping_token) @retry(tries=3) def _retry_status( self, connection: TCPSocketConnection, *, tries: int = 3, # ruff: ignore[unused-method-argument] version: int, ping_token: int | None, ) -> JavaStatusResponse: java_client = JavaClient( connection, address=self.address, version=version, ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int ) java_client.handshake() result = java_client.read_status() return result async def async_status(self, *, tries: int = 3, version: int = 47, ping_token: int | None = None) -> JavaStatusResponse: """Asynchronously check the status of a Minecraft Java Edition server via the status protocol. :param tries: The number of times to retry if an error is encountered. :param version: Version of the client, see https://minecraft.wiki/w/Protocol_version#List_of_protocol_versions. :param ping_token: Token of the packet, default is a random number. :return: Status information in a :class:`~mcstatus.responses.JavaStatusResponse` instance. """ async with TCPAsyncSocketConnection(self.address, self.timeout) as connection: return await self._retry_async_status(connection, tries=tries, version=version, ping_token=ping_token) @retry(tries=3) async def _retry_async_status( self, connection: TCPAsyncSocketConnection, *, tries: int = 3, # ruff: ignore[unused-method-argument] version: int, ping_token: int | None, ) -> JavaStatusResponse: java_client = AsyncJavaClient( connection, address=self.address, version=version, ping_token=ping_token, # pyright: ignore[reportArgumentType] # None is not assignable to int ) await java_client.handshake() result = await java_client.read_status() return result def query(self, *, tries: int = 3) -> QueryResponse: """Check the status of a Minecraft Java Edition server via the query protocol. :param tries: The number of times to retry if an error is encountered. :return: Query information in a :class:`~mcstatus.responses.QueryResponse` instance. """ ip = str(self.address.resolve_ip()) return self._retry_query(Address(ip, self.query_port), tries=tries) @retry(tries=3) def _retry_query(self, addr: Address, tries: int = 3) -> QueryResponse: # ruff: ignore[unused-method-argument] with UDPSocketConnection(addr, self.timeout) as connection: query_client = QueryClient(connection) query_client.handshake() return query_client.read_query() async def async_query(self, *, tries: int = 3) -> QueryResponse: """Asynchronously check the status of a Minecraft Java Edition server via the query protocol. :param tries: The number of times to retry if an error is encountered. :return: Query information in a :class:`~mcstatus.responses.QueryResponse` instance. """ ip = str(await self.address.async_resolve_ip()) return await self._retry_async_query(Address(ip, self.query_port), tries=tries) @retry(tries=3) async def _retry_async_query(self, address: Address, tries: int = 3) -> QueryResponse: # ruff: ignore[unused-method-argument] async with UDPAsyncSocketConnection(address, self.timeout) as connection: query_client = AsyncQueryClient(connection) await query_client.handshake() return await query_client.read_query() @final class LegacyServer(BaseJavaServer): """Base class for a pre-1.7 Minecraft Java Edition server. .. versionadded:: 12.1.0 """ @retry(tries=3) def status(self, *, tries: int = 3) -> LegacyStatusResponse: # ruff: ignore[unused-method-argument] """Check the status of a pre-1.7 Minecraft Java Edition server. :param tries: The number of times to retry if an error is encountered. :return: Status information in a :class:`~mcstatus.responses.LegacyStatusResponse` instance. """ with TCPSocketConnection(self.address, self.timeout) as connection: return LegacyClient(connection).read_status() @retry(tries=3) async def async_status(self, *, tries: int = 3) -> LegacyStatusResponse: # ruff: ignore[unused-method-argument] """Asynchronously check the status of a pre-1.7 Minecraft Java Edition server. :param tries: The number of times to retry if an error is encountered. :return: Status information in a :class:`~mcstatus.responses.LegacyStatusResponse` instance. """ async with TCPAsyncSocketConnection(self.address, self.timeout) as connection: return await AsyncLegacyClient(connection).read_status() @final class BedrockServer(MCServer): """Base class for a Minecraft Bedrock Edition server.""" DEFAULT_PORT = 19132 @retry(tries=3) def status(self, *, tries: int = 3) -> BedrockStatusResponse: # ruff: ignore[unused-method-argument] """Check the status of a Minecraft Bedrock Edition server. :param tries: The number of times to retry if an error is encountered. :return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance. """ return BedrockClient(self.address, self.timeout).read_status() @retry(tries=3) async def async_status(self, *, tries: int = 3) -> BedrockStatusResponse: # ruff: ignore[unused-method-argument] """Asynchronously check the status of a Minecraft Bedrock Edition server. :param tries: The number of times to retry if an error is encountered. :return: Status information in a :class:`~mcstatus.responses.BedrockStatusResponse` instance. """ return await BedrockClient(self.address, self.timeout).read_status_async() py-mine-mcstatus-341e7a4/pyproject.toml000066400000000000000000000204651524603215100202050ustar00rootroot00000000000000[project] name = "mcstatus" dynamic = ["version"] license = "Apache-2.0" description = "A library to query Minecraft Servers for their status and capabilities." readme = "README.md" authors = [ { name = "Nathan Adams", email = "dinnerbone@dinnerbone.com" }, { name = "ItsDrike", email = "itsdrike@protonmail.com" }, { name = "PerchunPak", email = "perchunpak@gmail.com" }, ] maintainers = [ { name = "Kevin Tindall", email = "kevinkjt2000@gmail.com" }, { name = "ItsDrike", email = "itsdrike@protonmail.com" }, { name = "PerchunPak", email = "perchunpak@gmail.com" }, ] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Topic :: Games/Entertainment", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: System :: Monitoring", "Typing :: Typed", ] keywords = ["minecraft", "protocol"] requires-python = ">=3.10" dependencies = ["asyncio-dgram>=2.1.2", "dnspython>=2.4.2"] [project.urls] Documentation = "https://mcstatus.readthedocs.io" "Source code" = "https://github.com/py-mine/mcstatus" [tool.uv] default-groups = ["dev", "lint", "test", "docs"] [dependency-groups] dev = ["poethepoet~=0.48.0"] lint = [ "pre-commit~=4.6.0", "ruff~=0.16.0", "basedpyright~=1.39.2", "typing-extensions~=4.16.0", ] test = [ "pytest~=9.1.0", "pytest-asyncio~=1.4.0", "pytest-cov~=7.1.0", "pytest-rerunfailures~=16.1", "coverage~=7.16.0", "typing-extensions~=4.16.0", # the actual versions are in the `release` group "uv-dynamic-versioning", "hatchling", ] docs = ["docs; python_version >= '3.12'"] release = ["hatchling~=1.32.0", "uv-dynamic-versioning~=0.14.0"] [tool.poe.tasks] _lint_ruff = "ruff check ." _lint_pyright = "basedpyright ." [tool.poe.tasks.build] cmd = "uv build" help = "Builds the whl and tar.gz distributions" [tool.poe.tasks.docs] cmd = "make -C docs/ html" help = "Generates documentation locally in html format" [tool.poe.tasks.format] cmd = "ruff format" help = "Runs automatic formatting tools" [tool.poe.tasks.lint] sequence = ["_lint_pyright", "_lint_ruff"] help = "Runs linter tools" [tool.poe.tasks.pre-commit] cmd = "pre-commit run --all-files" help = "Executes commit hook checks on all files" [tool.poe.tasks.release] cmd = "uv publish" deps = ["pre-commit", "test", "build"] help = "Requires all the checks to pass before building and publishing" [tool.poe.tasks.test] cmd = "pytest" help = "Runs the unit tests" [tool.pytest.ini_options] minversion = "6.0" asyncio_mode = "auto" tmp_path_retention_policy = "failed" addopts = "--strict-markers --doctest-modules --cov -vvv" testpaths = ["tests"] # Remove deprecation warning asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] precision = 2 # number of decimal places fail_under = 80 show_missing = true skip_covered = false skip_empty = false sort = "cover" exclude_lines = [ "\\#\\s*pragma: no cover", "^\\s*if (t\\.|typing\\.)?TYPE_CHECKING:", "^\\s*@(t|typing\\.)?overload", "^\\s*@(abc\\.)?abstractmethod", "^\\s*def __repr__\\(", "^\\s*class .*\\bProtocol\\):", "^\\s*raise NotImplementedError", "^\\s*return NotImplemented", "^\\s*\\.\\.\\.\\s", "^\\s*if __name__ == \"__main__\":", ] [tool.coverage.run] relative_files = true parallel = true branch = true timid = false source = ["mcstatus"] [tool.basedpyright] pythonPlatform = "All" pythonVersion = "3.10" typeCheckingMode = "all" # Diagnostic behavior settings strictListInference = false strictDictionaryInference = false strictSetInference = false strictParameterNoneValue = true analyzeUnannotatedFunctions = true enableTypeIgnoreComments = false enableExperimentalFeatures = false # Diagnostic rules reportAny = false reportExplicitAny = false reportImplicitStringConcatenation = false reportUnreachable = "hint" reportUnusedParameter = "hint" reportUnannotatedClassAttribute = true # might be too strict reportUnknownVariableType = true # might be too strict reportUnknownMemberType = true # might be too strict reportUnknownArgumentType = true # might be too strict reportUnknownParameterType = true # might be too strict reportUnknownLambdaType = false reportMissingTypeStubs = "information" reportMissingParameterType = false # ruff's flake8-annotations (ANN) already covers this + gives us more control reportIncompatibleUnannotatedOverride = false # https://github.com/DetachHead/basedpyright/issues/1349 reportUninitializedInstanceVariable = false # consider enabling executionEnvironments = [ { root = "tests", extraPaths = ["."], reportPrivateUsage = false }, { root = "docs/examples", extraPaths = ["."], reportUnusedCallResult = false } ] [tool.ruff] target-version = "py310" line-length = 127 [tool.ruff.lint] select = ["ALL"] preview = true ignore = [ "EM", # Very weird rules for using exceptions "FIX", # Line contains TODO, consider resolving the issue "assert", "compare-to-empty-string", # Explicit comparison against empty string (already falsy) "magic-value-comparison", "missing-copyright-notice", "missing-todo-author", "missing-todo-link", "missing-trailing-comma", "multi-line-implicit-string-concatenation", "no-self-use", # Method could be a function, class method, or static method -> https://github.com/astral-sh/ruff/issues/24713 "raise-vanilla-args", # Avoid specifying long messages outside the exception class "suspicious-non-cryptographic-random-usage", # Standard pseudo-random generators are not suitable for cryptographic purposes "unnecessary-assign", "docstring-missing-exception", "docstring-missing-returns", "docstring-missing-yields", "incorrect-blank-line-before-class", "missing-blank-line-after-last-section", "multi-line-summary-second-line", "undocumented-magic-method", "undocumented-public-class", "undocumented-public-function", "undocumented-public-init", "undocumented-public-method", "undocumented-public-module", "undocumented-public-nested-class", "undocumented-public-package", ] [tool.ruff.lint.per-file-ignores] "tests/**" = [ "ANN", # flake8-annotations "boolean-type-hint-positional-argument", "import-private-name", "no-self-use", "non-empty-init-module", "print", "private-member-access", "too-many-public-methods", "unused-function-argument", "unused-method-argument", ] "docs/**" = [ "implicit-namespace-package", ] "docs/examples/code/**" = [ "FA", # flake8-future-annotations "blind-except", "print", "typing-only-first-party-import", ] [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" banned-module-level-imports = ["typing_extensions"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "mcstatus._compat".msg = "Deprecated compatibility shims should not be imported" [tool.ruff.lint.isort] order-by-type = false case-sensitive = true combine-as-imports = true # Redundant rules with ruff-format force-single-line = false # forces all imports to appear on their own line force-wrap-aliases = false # Split imports with multiple members and at least one alias lines-after-imports = -1 # The number of blank lines to place after imports lines-between-types = 0 # Number of lines to place between "direct" and import from imports split-on-trailing-comma = false # if last member of multiline import has a comma, don't fold it to single line [tool.ruff.lint.flake8-annotations] allow-star-arg-any = true [tool.ruff.lint.pylint] max-args = 10 [tool.ruff.lint.flake8-builtins] ignorelist = ["id", "copyright"] [tool.ruff.format] line-ending = "lf" [project.scripts] mcstatus = "mcstatus.__main__:main" [build-system] requires = ["hatchling", "uv-dynamic-versioning"] build-backend = "hatchling.build" [tool.uv.sources] docs = { path = "docs" } [tool.hatch.version] source = "uv-dynamic-versioning" [tool.uv-dynamic-versioning] metadata = false fallback-version = "0.0.0" [tool.hatch.build.targets.sdist.force-include] "mcstatus/_compat/forge_data.py" = "mcstatus/forge_data.py" py-mine-mcstatus-341e7a4/tests/000077500000000000000000000000001524603215100164245ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/__init__.py000066400000000000000000000000001524603215100205230ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/helpers.py000066400000000000000000000022211524603215100204350ustar00rootroot00000000000000import collections.abc as c import importlib.metadata from contextlib import contextmanager from functools import wraps from unittest.mock import patch from mcstatus._utils.deprecation import LIB_NAME, _get_project_version @contextmanager def patch_project_version(version: str | None) -> c.Generator[None]: """Patch the project version reported by ``importlib.metadata.version``. This is used to simulate different project versions for testing purposes. If ``version`` is ``None``, a :exc:`PackageNotFoundError` will be raised when trying to get the project version. """ orig_version_func = importlib.metadata.version @wraps(orig_version_func) def patched_version_func(distribution_name: str) -> str: if distribution_name == LIB_NAME: if version is None: raise importlib.metadata.PackageNotFoundError return version return orig_version_func(distribution_name) _get_project_version.cache_clear() with patch.object(importlib.metadata, "version", new=patched_version_func): try: yield finally: _get_project_version.cache_clear() py-mine-mcstatus-341e7a4/tests/motd/000077500000000000000000000000001524603215100173675ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/motd/__init__.py000066400000000000000000000000001524603215100214660ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/motd/conftest.py000066400000000000000000000044361524603215100215750ustar00rootroot00000000000000import pytest from mcstatus.responses._raw import RawJavaResponseMotd @pytest.fixture(scope="session") def source_java() -> RawJavaResponseMotd: """Return ultimate dict with almost all possible aspects, which we should support. If feature can handle all from this dict, it's fully tested. Parser should have more tests, on additional features. """ return { "extra": [ {"text": "1"}, {"color": "#b3eeff", "text": "2"}, {"obfuscated": True, "color": "black", "text": "3"}, {"bold": True, "strikethrough": True, "color": "dark_blue", "text": "4"}, {"italic": True, "color": "dark_green", "text": "5"}, {"underlined": True, "color": "dark_aqua", "text": "6"}, {"color": "dark_aqua", "text": "7"}, {"color": "dark_red", "text": "8"}, {"color": "dark_purple", "text": "9"}, {"color": "gold", "text": "10"}, {"color": "gray", "text": "11"}, {"color": "dark_gray", "text": "12"}, {"color": "blue", "text": "13"}, {"color": "green", "text": "14"}, {"color": "aqua", "text": "15"}, {"color": "red", "text": "16"}, {"color": "light_purple", "text": "17"}, {"color": "yellow", "text": "18"}, {"color": "white", "text": "19"}, {"color": "reset", "text": "20"}, {"translate": "some.random.string"}, ], "text": "top", } @pytest.fixture(scope="session") def source_bedrock() -> RawJavaResponseMotd: """Return ultimate dict with almost all possible aspects, which we should support. If feature can handle all from this dict, it's fully tested. Parser should have more tests, on additional features. """ return ( "1" "§0§k2" "§1§l3" "§2§o4" "§35" "§46" "§57" "§68" "§79" "§810" "§911" "§a12" "§b13" "§c14" "§d15" "§e16" "§f17" "§g18" "§h19" "§i20" "§j21" "§m22" "§n23" "§p24" "§q25" "§s26" "§t27" "§u28" "§v29" "§z30" "§r31" ) # fmt: skip py-mine-mcstatus-341e7a4/tests/motd/test_components.py000066400000000000000000000033571524603215100231750ustar00rootroot00000000000000from __future__ import annotations import pytest from mcstatus.motd.components import WebColor class TestWebColor: @pytest.mark.parametrize( ("hex_", "rgb"), [ ("#bfff00", (191, 255, 0)), ("#00ff80", (0, 255, 128)), ("#4000ff", (64, 0, 255)), ], ) def test_hex_to_rgb_correct(self, hex_: str, rgb: tuple[int, int, int]): assert WebColor.from_hex(hex=hex_).rgb == rgb @pytest.mark.parametrize( ("hex_", "rgb"), [ ("#bfff00", (191, 255, 0)), ("#00ff80", (0, 255, 128)), ("#4000ff", (64, 0, 255)), ], ) def test_rgb_to_hex_correct(self, hex_: str, rgb: tuple[int, int, int]): assert WebColor.from_rgb(rgb=rgb).hex == hex_ def test_hex_in_output_has_number_sign(self): assert WebColor.from_hex(hex="#bfff00").hex == "#bfff00" assert WebColor.from_hex(hex="4000ff").hex == "#4000ff" def test_fail_on_incorrect_hex(self): with pytest.raises(ValueError, match=r"^Failed to parse given hex color: '#!!!!!!'$"): _ = WebColor.from_hex(hex="#!!!!!!") @pytest.mark.parametrize("length", [0, 1, 2, 4, 5, 7, 8, 9, 10]) def test_fail_on_too_long_or_too_short_hex(self, length: int): color = "a" * length with pytest.raises(ValueError, match=f"^Got too long/short hex color: '#{color}'$"): _ = WebColor.from_hex(hex="a" * length) def test_fail_on_incorrect_rgb(self): with pytest.raises(ValueError, match=r"^RGB color byte out of its 8-bit range \(0-255\) for red \(value=-23\)$"): _ = WebColor.from_rgb(rgb=(-23, 699, 1000)) def test_3_symbols_hex(self): assert WebColor.from_hex("a1b").hex == "#aa11bb" py-mine-mcstatus-341e7a4/tests/motd/test_motd_parse.py000066400000000000000000000221111524603215100231320ustar00rootroot00000000000000from __future__ import annotations import pytest from mcstatus.motd import Motd from mcstatus.motd.components import ( BedrockFormatting, BedrockMinecraftColor, InvalidFormatting, JavaFormatting, JavaMinecraftColor, ParsedMotdComponent, TranslationTag, ) from mcstatus.responses._raw import RawJavaResponseMotd, RawJavaResponseMotdWhenDict class TestMotdParse: def test_correct_result_bedrock(self, source_bedrock: RawJavaResponseMotd): assert Motd.parse(source_bedrock, bedrock=True) == Motd( [ "1", BedrockMinecraftColor.BLACK, BedrockFormatting.OBFUSCATED, "2", BedrockMinecraftColor.DARK_BLUE, BedrockFormatting.BOLD, "3", BedrockMinecraftColor.DARK_GREEN, BedrockFormatting.ITALIC, "4", BedrockMinecraftColor.DARK_AQUA, "5", BedrockMinecraftColor.DARK_RED, "6", BedrockMinecraftColor.DARK_PURPLE, "7", BedrockMinecraftColor.GOLD, "8", BedrockMinecraftColor.GRAY, "9", BedrockMinecraftColor.DARK_GRAY, "10", BedrockMinecraftColor.BLUE, "11", BedrockMinecraftColor.GREEN, "12", BedrockMinecraftColor.AQUA, "13", BedrockMinecraftColor.RED, "14", BedrockMinecraftColor.LIGHT_PURPLE, "15", BedrockMinecraftColor.YELLOW, "16", BedrockMinecraftColor.WHITE, "17", BedrockMinecraftColor.MINECOIN_GOLD, "18", BedrockMinecraftColor.MATERIAL_QUARTZ, "19", BedrockMinecraftColor.MATERIAL_IRON, "20", BedrockMinecraftColor.MATERIAL_NETHERITE, "21", BedrockMinecraftColor.MATERIAL_REDSTONE, "22", BedrockMinecraftColor.MATERIAL_COPPER, "23", BedrockMinecraftColor.MATERIAL_GOLD, "24", BedrockMinecraftColor.MATERIAL_EMERALD, "25", BedrockMinecraftColor.MATERIAL_DIAMOND, "26", BedrockMinecraftColor.MATERIAL_LAPIS, "27", BedrockMinecraftColor.MATERIAL_AMETHYST, "28", BedrockMinecraftColor.MATERIAL_RESIN, "29", InvalidFormatting("z"), "30", BedrockFormatting.RESET, "31", ], bedrock=True, raw=source_bedrock, ) # fmt: skip @pytest.mark.parametrize( ("bedrock", "expected"), [(True, BedrockMinecraftColor.MINECOIN_GOLD), (False, InvalidFormatting("g"))] ) def test_parse_as_str_ignore_minecoin_gold_on_java(self, bedrock: bool, expected: ParsedMotdComponent): assert Motd.parse("&g", bedrock=bedrock).parsed == [expected] @pytest.mark.parametrize( ("bedrock", "expected"), [(True, BedrockMinecraftColor.MATERIAL_IRON), (False, InvalidFormatting("i"))] ) def test_parse_as_str_ignore_material_colors_on_java(self, bedrock: bool, expected: ParsedMotdComponent): assert Motd.parse("&i", bedrock=bedrock).parsed == [expected] @pytest.mark.parametrize( ("bedrock", "expected"), [(True, BedrockMinecraftColor.MATERIAL_COPPER), (False, JavaFormatting.UNDERLINED)] ) def test_parse_as_str_underlined_on_java(self, bedrock: bool, expected: ParsedMotdComponent): assert Motd.parse("&n", bedrock=bedrock).parsed == [expected] def test_parse_incorrect_formatting(self): assert Motd.parse("&z").parsed == [InvalidFormatting("z")] def test_parse_uppercase_passes(self): assert Motd.parse("&A").parsed == [JavaMinecraftColor.GREEN] @pytest.mark.parametrize( ("input_", "expected"), [("", []), ([], [JavaFormatting.RESET]), ({"extra": [], "text": ""}, [JavaFormatting.RESET])], ) def test_empty_input_also_empty_raw(self, input_: RawJavaResponseMotd, expected: list[ParsedMotdComponent]): assert Motd.parse(input_).parsed == expected def test_top_level_formatting_applies_to_all_in_extra(self) -> None: """As described `here `_.""" assert Motd.parse({"text": "top", "bold": True, "extra": [{"color": "red", "text": "not top"}]}).parsed == [ JavaFormatting.BOLD, "top", JavaFormatting.RESET, JavaFormatting.BOLD, JavaMinecraftColor.RED, "not top", JavaFormatting.RESET, ] def test_top_level_formatting_can_be_overwrote(self) -> None: """As described `here `_.""" assert Motd.parse( {"text": "bold", "bold": True, "extra": [{"color": "red", "bold": False, "text": "not bold"}]} ).parsed == [ JavaFormatting.BOLD, "bold", JavaFormatting.RESET, JavaMinecraftColor.RED, "not bold", JavaFormatting.RESET, ] def test_top_level_formatting_applies_to_string_inside_extra(self) -> None: """Although, it is probably a bug in some modded cores, Minecraft supports it, and we should as well. See `#711 `_. """ assert Motd.parse({"text": "top", "bold": True, "extra": ["not top"]}).parsed == [ JavaFormatting.BOLD, "top", JavaFormatting.RESET, JavaFormatting.BOLD, "not top", ] def test_formatting_key_set_to_false_here_without_it_being_set_to_true_before(self) -> None: """Some servers set the formatting keys to false here, even without it ever being set to true before. See `https://github.com/py-mine/mcstatus/pull/335#discussion_r985086953`_. """ assert Motd.parse({"color": "red", "bold": False, "text": "not bold"}).parsed == [ JavaMinecraftColor.RED, "not bold", JavaFormatting.RESET, ] def test_translate_string(self): assert Motd.parse(RawJavaResponseMotdWhenDict(translate="the key")).parsed == [ TranslationTag("the key"), JavaFormatting.RESET, ] def test_short_text_is_not_considered_as_color(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r984535349`_.""" assert Motd.parse("a").parsed == ["a"] def test_text_field_contains_formatting(self): """See `https://github.com/py-mine/mcstatus/pull/335#issuecomment-1264191303`_.""" assert Motd.parse({"text": "&aHello!"}).parsed == [JavaMinecraftColor.GREEN, "Hello!", JavaFormatting.RESET] def test_invalid_raw_input(self): obj = object() with pytest.raises( TypeError, match=f"^Expected list, string or dict data, got \\({obj!r}\\), report this!$", ): _ = Motd.parse(obj) # pyright: ignore[reportArgumentType] def test_parse_invalid_color(self): with pytest.raises(ValueError, match=r"^Unable to parse color: 'a', report this!$"): _ = Motd._parse_color("a", bedrock=False) def test_multiple_times_nested_extras(self): """See `https://discord.com/channels/936788458939224094/938591600160956446/1062860329597534258`_.""" motd = Motd.parse( { "extra": [ { "extra": [ {"extra": [{"text": "1"}]}, {"extra": [{"text": "2"}]}, {"extra": [{"text": "3"}]}, ] }, { "extra": [ {"extra": [{"text": "4"}]}, {"extra": [{"text": "5"}]}, {"extra": [{"text": "6"}]}, ] }, { "extra": [ {"extra": [{"text": "7"}]}, {"extra": [{"text": "8"}]}, {"extra": [{"text": "9"}]}, ] }, ] } ) assert motd.parsed == [ JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET, "1", JavaFormatting.RESET, JavaFormatting.RESET, "2", JavaFormatting.RESET, JavaFormatting.RESET, "3", JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET, "4", JavaFormatting.RESET, JavaFormatting.RESET, "5", JavaFormatting.RESET, JavaFormatting.RESET, "6", JavaFormatting.RESET, JavaFormatting.RESET, JavaFormatting.RESET, "7", JavaFormatting.RESET, JavaFormatting.RESET, "8", JavaFormatting.RESET, JavaFormatting.RESET, "9", JavaFormatting.RESET, ] # fmt: skip def test_raw_attribute(self, source_bedrock: RawJavaResponseMotd): motd = Motd.parse(source_bedrock, bedrock=True) assert motd.raw == source_bedrock py-mine-mcstatus-341e7a4/tests/motd/test_simplifies.py000066400000000000000000000202471524603215100231510ustar00rootroot00000000000000from __future__ import annotations from contextlib import ExitStack from typing import Any from unittest import mock import pytest from mcstatus.motd import Motd from mcstatus.motd._simplifies import ( get_double_colors, get_double_items, get_end_non_text, get_formatting_before_color, get_meaningless_resets_and_colors, get_unused_elements, ) from mcstatus.motd.components import JavaFormatting, JavaMinecraftColor, ParsedMotdComponent, TranslationTag, WebColor class TestMotdSimplifies: def test_get_unused_elements_call_every_simplifier(self): with ExitStack() as stack: mocked = [ stack.enter_context(mock.patch("mcstatus.motd._simplifies." + simplifier)) for simplifier in [ get_double_items.__name__, get_double_colors.__name__, get_formatting_before_color.__name__, get_end_non_text.__name__, ] ] _ = get_unused_elements([]) for simplifier in mocked: simplifier.assert_called() def test_simplify_returns_new_instance(self): parsed: list[ParsedMotdComponent] = [JavaFormatting.RESET] obj = Motd(parsed.copy(), raw="") assert obj.simplify().parsed == [] assert obj.parsed == parsed def test_simplifies_work(self): _ = get_unused_elements(["a", "b", "c"]) def test_simplify_runs_few_times(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1051658497`_.""" obj = Motd([JavaFormatting.BOLD, JavaFormatting.RESET, JavaMinecraftColor.RED], raw="") assert obj.simplify() == Motd([], raw="") @pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) def test_get_double_colors(self, first: ParsedMotdComponent, second: ParsedMotdComponent): assert get_double_colors([first, second]) == {0} @pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) @pytest.mark.parametrize("third", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="dd0220")]) def test_get_double_colors_with_three_items( self, first: ParsedMotdComponent, second: ParsedMotdComponent, third: ParsedMotdComponent, ): assert get_double_colors([first, second, third]) == {0, 1} @pytest.mark.parametrize("first", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [JavaMinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) def test_get_double_colors_with_no_double_colors(self, first: ParsedMotdComponent, second: ParsedMotdComponent): assert get_double_colors([first, "foo", second]) == set() @pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items(self, item: ParsedMotdComponent): assert get_double_items([item, item]) == {0} @pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items_with_three_items(self, item: ParsedMotdComponent): assert get_double_items([item, item, item]) == {0, 1} @pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items_with_no_double_items(self, item: ParsedMotdComponent): assert get_double_items([item, "foo", item]) == set() @pytest.mark.parametrize("last_item", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_formatting_before_color(self, last_item: ParsedMotdComponent): assert get_formatting_before_color([JavaFormatting.BOLD, last_item]) == {0} @pytest.mark.parametrize( "first_item", [JavaFormatting.RESET, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000"), "abc"] ) def test_get_formatting_before_color_without_formatting_before_color(self, first_item: ParsedMotdComponent): assert get_formatting_before_color([first_item, "abc", JavaMinecraftColor.WHITE]) == set() def test_skip_get_formatting_before_color(self): assert get_formatting_before_color(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi"]) == set() @pytest.mark.parametrize("last_item", [JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_formatting_before_color_if_space_between(self, last_item: ParsedMotdComponent): assert get_formatting_before_color([JavaFormatting.BOLD, " ", last_item]) == {0} def test_two_formattings_before_minecraft_color(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1048476090`_.""" assert get_formatting_before_color([JavaFormatting.BOLD, JavaFormatting.ITALIC, JavaMinecraftColor.RED]) == {0, 1} def test_two_formattings_one_by_one(self): obj = Motd([JavaFormatting.BOLD, JavaFormatting.ITALIC], raw="") assert obj.simplify().parsed == [] @pytest.mark.parametrize("last_item", [JavaFormatting.RESET, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_non_text_in_the_end(self, last_item: ParsedMotdComponent): assert get_end_non_text(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi", last_item]) == {5} def test_translation_tag_in_the_end(self): assert ( get_end_non_text(["abc", JavaFormatting.BOLD, "def", JavaFormatting.RESET, "ghi", TranslationTag("key")]) == set() ) @pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_meaningless_resets_and_colors_active(self, item: ParsedMotdComponent): assert get_meaningless_resets_and_colors([item, "foo", item, "bar"]) == {2} def test_meaningless_resets_and_colors_reset_nothing(self): assert get_meaningless_resets_and_colors(["foo", JavaFormatting.RESET, "bar"]) == {1} @pytest.mark.parametrize("item", [JavaFormatting.BOLD, JavaMinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_meaningless_resets_and_colors_resets(self, item: ParsedMotdComponent): assert get_meaningless_resets_and_colors([item, "foo", JavaFormatting.RESET, item, "bar"]) == set() def test_no_conflict_on_popping_items(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1045303652`_.""" obj = Motd(["0", "1"], raw="") call_count = 0 def remove_first_element(*_: Any, **__: Any) -> set[int]: nonlocal call_count call_count += 1 if call_count in {1, 2}: return {0} return set() with ExitStack() as stack: for simplifier in [ get_double_items.__name__, get_double_colors.__name__, get_formatting_before_color.__name__, get_end_non_text.__name__, ]: _ = stack.enter_context(mock.patch("mcstatus.motd._simplifies." + simplifier, remove_first_element)) assert obj.simplify().parsed == ["1"] def test_simplify_function_provides_the_same_raw(self): obj = object() assert Motd([], raw=obj).simplify().raw is obj # pyright: ignore[reportArgumentType] def test_simplify_do_not_remove_string_contains_only_spaces(self): """Those can be used as delimiters.""" assert Motd([" " * 20], raw="").simplify().parsed == [" " * 20] def test_simplify_meaningless_resets_and_colors(self): assert Motd.parse("&a1&a2&a3").simplify().parsed == [JavaMinecraftColor.GREEN, "123"] def test_remove_formatting_reset_if_there_was_no_color_or_formatting(self): motd = Motd.parse({"text": "123", "extra": [{"text": "123"}]}) assert motd.parsed == ["123", JavaFormatting.RESET, "123", JavaFormatting.RESET] assert motd.simplify().parsed == ["123123"] def test_squash_nearby_strings(self): assert Motd(["123", "123", "123"], raw="").simplify().parsed == ["123123123"] py-mine-mcstatus-341e7a4/tests/motd/test_transformers.py000066400000000000000000000235521524603215100235340ustar00rootroot00000000000000# ruff: file-ignore[boolean-positional-value-in-call] from __future__ import annotations import typing from collections.abc import Callable import pytest from mcstatus.motd import Motd from mcstatus.motd._transformers import _NothingTransformer from mcstatus.responses._raw import RawJavaResponseMotd MotdParseFunc: typing.TypeAlias = Callable[[RawJavaResponseMotd], str] MotdParseFuncBedrockFlag: typing.TypeAlias = Callable[[RawJavaResponseMotd, bool], str] def test_nothing_transformer(): assert _NothingTransformer(bedrock=False).transform(Motd.parse("&1a&bfo&zo&r").parsed) == "" class TestMotdPlain: @pytest.fixture(scope="class") @staticmethod def result() -> MotdParseFunc: return lambda text: Motd.parse(text).to_plain() def test_plain_text(self, result: MotdParseFunc): assert result("plain") == "plain" def test_removes_colors(self, result: MotdParseFunc): assert result("&1<ext") == "text" def test_skip_web_colors(self, result: MotdParseFunc): assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "colored text" def test_skip_minecraft_colors(self, result: MotdParseFunc): assert result({"extra": [{"color": "red", "text": "colored text"}], "text": ""}) == "colored text" class TestMotdMinecraft: @pytest.fixture(scope="class") @staticmethod def result() -> MotdParseFunc: return lambda text: Motd.parse(text).to_minecraft() @pytest.mark.parametrize("motd", ["&1&2&3&z", "§123§5bc&z", "§1§2§3§z"]) @staticmethod def test_return_the_same(motd: str, result: MotdParseFunc): assert result(motd) == motd.replace("&", "§") def test_skip_web_colors(self, result: MotdParseFunc): assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "§rcolored text§r" class TestMotdHTML: @pytest.fixture(scope="class") @staticmethod def result() -> MotdParseFuncBedrockFlag: return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_html() def test_correct_output_java(self, result: MotdParseFuncBedrockFlag, source_java: RawJavaResponseMotd): assert result(source_java, False) == ( "

top" "12" "3" "" "4" "5" "6" "7" "8" "9" "10" "11" "12" "13" "14" "15" "16" "17" "18" "19" "20

" ) def test_correct_output_bedrock(self, result: MotdParseFuncBedrockFlag, source_bedrock: RawJavaResponseMotd): assert result(source_bedrock, True) == ( "

" "1" "2" "3" "4" "5" "6" "7" "8" "9" "10" "11" "12" "13" "14" "15" "16" "17" "18" "19" "20" "21" "22" "23" "24" "25" "26" "27" "28" "2930" "31" "

" ) def test_new_line_is_br_tag(self): motd = Motd.parse("Some cool\ntext") assert motd.to_html() == "

Some cool
text

" class TestMotdAnsi: @pytest.fixture(scope="class") @staticmethod def result() -> MotdParseFuncBedrockFlag: return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_ansi() def test_correct_output_java(self, result: MotdParseFuncBedrockFlag, source_java: RawJavaResponseMotd): assert result(source_java, False) == ( "\033[0mtop\033[0m" "1\033[0m" "\033[38;2;179;238;255m2\033[0m\033[0m" "\033[38;2;0;0;0m\033[5m3\033[0m\033[0m" "\033[38;2;0;0;170m\033[1m\033[9m4\033[0m\033[0m" "\033[38;2;0;170;0m\033[3m5\033[0m\033[0m" "\033[38;2;0;170;170m\033[4m6\033[0m\033[0m" "\033[38;2;0;170;170m7\033[0m\033[0m" "\033[38;2;170;0;0m8\033[0m\033[0m" "\033[38;2;170;0;170m9\033[0m\033[0m" "\033[38;2;255;170;0m10\033[0m\033[0m" "\033[38;2;170;170;170m11\033[0m\033[0m" "\033[38;2;85;85;85m12\033[0m\033[0m" "\033[38;2;85;85;255m13\033[0m\033[0m" "\033[38;2;85;255;85m14\033[0m\033[0m" "\033[38;2;85;255;255m15\033[0m\033[0m" "\033[38;2;255;85;85m16\033[0m\033[0m" "\033[38;2;255;85;255m17\033[0m\033[0m" "\033[38;2;255;255;85m18\033[0m\033[0m" "\033[38;2;255;255;255m19\033[0m\033[0m" "20\033[0m" "\033[0m\033[0m" ) def test_correct_output_bedrock(self, result: MotdParseFuncBedrockFlag, source_bedrock: RawJavaResponseMotd): assert result(source_bedrock, True) == ( "\033[0m" "1\033[0m" "\033[38;2;0;0;0m\033[5m2" "\033[0m\033[38;2;0;0;170m\033[1m3" "\033[0m\033[38;2;0;170;0m\033[3m4" "\033[0m\033[38;2;0;170;170m5" "\033[0m\033[38;2;170;0;0m6" "\033[0m\033[38;2;170;0;170m7" "\033[0m\033[38;2;255;170;0m8" "\033[0m\033[38;2;198;198;198m9" "\033[0m\033[38;2;85;85;85m10" "\033[0m\033[38;2;85;85;255m11" "\033[0m\033[38;2;85;255;85m12" "\033[0m\033[38;2;85;255;255m13" "\033[0m\033[38;2;255;85;85m14" "\033[0m\033[38;2;255;85;255m15" "\033[0m\033[38;2;255;255;85m16" "\033[0m\033[38;2;255;255;255m17" "\033[0m\033[38;2;221;214;5m18" "\033[0m\033[38;2;227;212;209m19" "\033[0m\033[38;2;206;202;202m20" "\033[0m\033[38;2;68;58;59m21" "\033[0m\033[38;2;151;22;7m22" "\033[0m\033[38;2;180;104;77m23" "\033[0m\033[38;2;222;177;45m24" "\033[0m\033[38;2;17;159;54m25" "\033[0m\033[38;2;44;186;168m26" "\033[0m\033[38;2;33;73;123m27" "\033[0m\033[38;2;154;92;198m28" "\033[0m\033[38;2;235;114;20m29" "30\033[0m31\033[0m" ) py-mine-mcstatus-341e7a4/tests/net/000077500000000000000000000000001524603215100172125ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/net/__init__.py000066400000000000000000000000001524603215100213110ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/net/test_address.py000066400000000000000000000245131524603215100222550ustar00rootroot00000000000000from __future__ import annotations import ipaddress import sys from pathlib import Path from typing import Any, TYPE_CHECKING, final from unittest.mock import MagicMock, patch import dns.resolver import pytest from mcstatus._net.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup if TYPE_CHECKING: from dns.exception import DNSException @final class TestSRVLookup: @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) def test_address_no_srv(self, exception: DNSException): with patch("mcstatus._net.address.mc_dns.resolve_mc_srv", side_effect=exception): address = minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3) assert address.host == "example.org" assert address.port == 25565 @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) def test_address_no_srv_no_default_port(self, exception: DNSException): with ( patch("mcstatus._net.address.mc_dns.resolve_mc_srv", side_effect=exception), pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"), ): _ = minecraft_srv_address_lookup("example.org", lifetime=3) def test_address_with_srv(self): with patch("mcstatus._net.address.mc_dns.resolve_mc_srv", return_value=("different.example.org", 12345)): address = minecraft_srv_address_lookup("example.org", lifetime=3) assert address.host == "different.example.org" assert address.port == 12345 @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) async def test_async_address_no_srv(self, exception: DNSException): with patch("mcstatus._net.address.mc_dns.async_resolve_mc_srv", side_effect=exception): address = await async_minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3) assert address.host == "example.org" assert address.port == 25565 @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) async def test_async_address_no_srv_no_default_port(self, exception: DNSException): with ( patch("mcstatus._net.address.mc_dns.async_resolve_mc_srv", side_effect=exception), pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"), ): _ = await async_minecraft_srv_address_lookup("example.org", lifetime=3) async def test_async_address_with_srv(self): with patch("mcstatus._net.address.mc_dns.async_resolve_mc_srv", return_value=("different.example.org", 12345)): address = await async_minecraft_srv_address_lookup("example.org", lifetime=3) assert address.host == "different.example.org" assert address.port == 12345 @final class TestAddressValidity: @pytest.mark.parametrize( ("address", "port"), [ ("example.org", 25565), ("192.168.0.100", 54321), ("2345:0425:2CA1:0000:0000:0567:5673:23b5", 100), ("2345:0425:2CA1::0567:5673:23b5", 12345), ], ) def test_address_validation_valid(self, address: str, port: int): Address._ensure_validity(address, port) @pytest.mark.parametrize( ("address", "port"), [ ("example.org", 100_000), ("example.org", -1), ], ) def test_address_validation_range(self, address: str, port: int): with pytest.raises(ValueError, match=f"^Port must be within the allowed range \\(0-2\\^16\\), got {port}$"): Address._ensure_validity(address, port) def test_address_validation_port_invalid_type(self): with pytest.raises(TypeError, match=r"^Port must be an integer port number, got \('25565'\)$"): Address._ensure_validity("example.org", "25565") @pytest.mark.parametrize( ("address", "port"), [(25565, "example.org"), (0, 0)], ) def test_address_validation_host_invalid_type(self, address: Any, port: Any): with pytest.raises(TypeError, match=f"^Host must be a string address, got {type(address)!r} \\({address!r}\\)$"): Address._ensure_validity(address, port) def test_address_host_invalid_format(self): with pytest.raises(ValueError, match=r"^Invalid address 'hello@#', can't parse\.$"): _ = Address.parse_address("hello@#") @final class TestAddressConstructing: def test_init_constructor(self): addr = Address("example.org", 25565) assert addr.host == "example.org" assert addr.port == 25565 def test_tuple_behavior(self): addr = Address("example.org", 25565) assert isinstance(addr, tuple) assert len(addr) == 2 assert addr[0] == "example.org" assert addr[1] == 25565 def test_from_tuple_constructor(self): addr = Address.from_tuple(("example.org", 12345)) assert addr.host == "example.org" assert addr.port == 12345 def test_from_path_constructor(self): addr = Address.from_path(Path("example.org:25565")) assert addr.host == "example.org" assert addr.port == 25565 def test_address_with_port_no_default(self): addr = Address.parse_address("example.org:25565") assert addr.host == "example.org" assert addr.port == 25565 def test_address_with_port_default(self): addr = Address.parse_address("example.org:25565", default_port=12345) assert addr.host == "example.org" assert addr.port == 25565 def test_address_without_port_default(self): addr = Address.parse_address("example.org", default_port=12345) assert addr.host == "example.org" assert addr.port == 12345 def test_address_without_port(self): with pytest.raises( ValueError, match=r"^Given address 'example.org' doesn't contain port and default_port wasn't specified, can't parse.$", ): _ = Address.parse_address("example.org") def test_address_with_invalid_port(self): with pytest.raises(ValueError, match=r"^Port could not be cast to integer value as 'port'$"): _ = Address.parse_address("example.org:port") def test_address_with_multiple_ports(self): with pytest.raises(ValueError, match=r"^Port could not be cast to integer value as '12345:25565'$"): _ = Address.parse_address("example.org:12345:25565") @final class TestAddressIPResolving: def setup_method(self): self.host_addr = Address("example.org", 25565) self.ipv4_addr = Address("1.1.1.1", 25565) self.ipv6_addr = Address("::1", 25565) def test_ip_resolver_with_hostname(self): with patch("mcstatus._net.address.mc_dns.resolve_a_record", return_value="48.225.1.104"): resolved_ip = self.host_addr.resolve_ip(lifetime=3) assert isinstance(resolved_ip, ipaddress.IPv4Address) assert str(resolved_ip) == "48.225.1.104" async def test_async_ip_resolver_with_hostname(self): with patch("mcstatus._net.address.mc_dns.async_resolve_a_record", return_value="48.225.1.104"): resolved_ip = await self.host_addr.async_resolve_ip(lifetime=3) assert isinstance(resolved_ip, ipaddress.IPv4Address) assert str(resolved_ip) == "48.225.1.104" @pytest.mark.parametrize("ip_version", ["ipv4_addr", "ipv6_addr"]) def test_ip_resolver_cache(self, ip_version: str): with patch("dns.resolver.resolve"), patch("ipaddress.ip_address") as resolve: assert getattr(self, ip_version).resolve_ip(lifetime=3) is getattr(self, ip_version).resolve_ip(lifetime=3) resolve.assert_called_once() # Make sure we didn't needlessly try to resolve @pytest.mark.parametrize("ip_version", ["ipv4_addr", "ipv6_addr"]) async def test_async_ip_resolver_cache(self, ip_version: str): with patch("dns.resolver.resolve"), patch("ipaddress.ip_address") as resolve: assert await getattr(self, ip_version).async_resolve_ip(lifetime=3) is await getattr( self, ip_version ).async_resolve_ip(lifetime=3) resolve.assert_called_once() # Make sure we didn't needlessly try to resolve def test_ip_resolver_with_ipv4(self): with patch("dns.resolver.resolve") as resolve: resolved_ip = self.ipv4_addr.resolve_ip(lifetime=3) resolve.assert_not_called() # Make sure we didn't needlessly try to resolve assert isinstance(resolved_ip, ipaddress.IPv4Address) assert str(resolved_ip) == self.ipv4_addr.host async def test_async_ip_resolver_with_ipv4(self): with patch("dns.asyncresolver.resolve") as resolve: resolved_ip = await self.ipv4_addr.async_resolve_ip(lifetime=3) resolve.assert_not_called() # Make sure we didn't needlessly try to resolve assert isinstance(resolved_ip, ipaddress.IPv4Address) assert str(resolved_ip) == self.ipv4_addr.host def test_ip_resolver_with_ipv6(self): with patch("dns.resolver.resolve") as resolve: resolved_ip = self.ipv6_addr.resolve_ip(lifetime=3) resolve.assert_not_called() # Make sure we didn't needlessly try to resolve assert isinstance(resolved_ip, ipaddress.IPv6Address) assert str(resolved_ip) == self.ipv6_addr.host async def test_async_ip_resolver_with_ipv6(self): with patch("dns.asyncresolver.resolve") as resolve: resolved_ip = await self.ipv6_addr.async_resolve_ip(lifetime=3) resolve.assert_not_called() # Make sure we didn't needlessly try to resolve assert isinstance(resolved_ip, ipaddress.IPv6Address) assert str(resolved_ip) == self.ipv6_addr.host def test_resolve_localhost(self): addr = Address("localhost", 25565) context_manager = pytest.warns(RuntimeWarning) if sys.platform == "darwin" else MagicMock() with context_manager: assert addr.resolve_ip() == ipaddress.ip_address("127.0.0.1") async def test_async_resolve_localhost(self): addr = Address("localhost", 25565) context_manager = pytest.warns(RuntimeWarning) if sys.platform == "darwin" else MagicMock() with context_manager: assert await addr.async_resolve_ip() == ipaddress.ip_address("127.0.0.1") py-mine-mcstatus-341e7a4/tests/net/test_dns.py000066400000000000000000000113061524603215100214100ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, cast from unittest.mock import patch import dns.exception import dns.message import dns.name import dns.rdata import dns.rdataclass import dns.resolver import pytest from dns.rdatatype import RdataType from mcstatus._net.dns import async_resolve_a_record, resolve_a_record if TYPE_CHECKING: from collections.abc import Callable, Mapping def dns_answer(record_type: RdataType, ip: str | None) -> dns.resolver.Answer: qname = dns.name.from_text("example.org.") response = cast("dns.message.QueryMessage", dns.message.make_response(dns.message.make_query(qname, record_type))) if ip is not None: answer = response.find_rrset(dns.message.ANSWER, qname, dns.rdataclass.IN, record_type, create=True) answer.add(dns.rdata.from_text(dns.rdataclass.IN, record_type, ip), ttl=60) return dns.resolver.Answer(qname, record_type, dns.rdataclass.IN, response) def dns_mock( answers: Mapping[RdataType, dns.resolver.Answer], ) -> Callable[..., dns.resolver.Answer]: def resolve( qname: dns.name.Name | str, rdtype: RdataType | str = RdataType.A, rdclass: dns.rdataclass.RdataClass | str = dns.rdataclass.IN, tcp: bool = False, # ruff: ignore[boolean-default-value-positional-argument] source: str | None = None, raise_on_no_answer: bool = True, # ruff: ignore[boolean-default-value-positional-argument] source_port: int = 0, lifetime: float | None = None, search: bool | None = None, backend: object | None = None, ) -> dns.resolver.Answer: answer = answers[RdataType.make(rdtype)] if answer.rrset is None and raise_on_no_answer: raise dns.resolver.NoAnswer(response=answer.response) return answer return resolve class TestARecordResolution: def test_prefers_ipv4_record(self): answers = { RdataType.A: dns_answer(RdataType.A, "192.0.2.1"), RdataType.AAAA: dns_answer(RdataType.AAAA, "2001:db8::1"), } with patch("dns.resolver.resolve", side_effect=dns_mock(answers)): assert resolve_a_record("example.org", lifetime=3) == "192.0.2.1" def test_falls_back_to_ipv6_record(self): with patch( "dns.resolver.resolve", side_effect=dns_mock( { RdataType.A: dns_answer(RdataType.A, None), RdataType.AAAA: dns_answer(RdataType.AAAA, "2001:db8::1"), } ), ): assert resolve_a_record("example.org", lifetime=3) == "2001:db8::1" @pytest.mark.parametrize("exception", [dns.exception.Timeout, dns.resolver.NXDOMAIN]) def test_propagates_resolution_failure(self, exception: type[dns.exception.DNSException]): with ( patch("dns.resolver.resolve", side_effect=exception), pytest.raises(exception), ): _ = resolve_a_record("example.org") async def test_async_prefers_ipv4_record(self): answers = { RdataType.A: dns_answer(RdataType.A, "192.0.2.1"), RdataType.AAAA: dns_answer(RdataType.AAAA, "2001:db8::1"), } with patch( "dns.asyncresolver.resolve", side_effect=dns_mock(answers), ): assert await async_resolve_a_record("example.org", lifetime=3) == "192.0.2.1" async def test_async_falls_back_to_ipv6_record(self): with patch( "dns.asyncresolver.resolve", side_effect=dns_mock( { RdataType.A: dns_answer(RdataType.A, None), RdataType.AAAA: dns_answer(RdataType.AAAA, "2001:db8::1"), } ), ): assert await async_resolve_a_record("example.org") == "2001:db8::1" @pytest.mark.parametrize("exception", [dns.exception.Timeout, dns.resolver.NXDOMAIN]) async def test_async_propagates_resolution_failure(self, exception: type[dns.exception.DNSException]): with ( patch("dns.asyncresolver.resolve", side_effect=exception), pytest.raises(exception), ): _ = await async_resolve_a_record("example.org") async def test_async_raises_no_answer_when_no_records_exist(self): with ( patch( "dns.asyncresolver.resolve", side_effect=dns_mock( { RdataType.A: dns_answer(RdataType.A, None), RdataType.AAAA: dns_answer(RdataType.AAAA, None), } ), ), pytest.raises(dns.resolver.NoAnswer), ): _ = await async_resolve_a_record("example.org") py-mine-mcstatus-341e7a4/tests/protocol/000077500000000000000000000000001524603215100202655ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/protocol/__init__.py000066400000000000000000000000001524603215100223640ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/protocol/helpers.py000066400000000000000000000124361524603215100223070ustar00rootroot00000000000000from __future__ import annotations import asyncio from typing import Any, ParamSpec, TYPE_CHECKING, TypeVar, final from mcstatus._protocol.io.buffer import Buffer from mcstatus._protocol.io.connection import BaseAsyncConnection, BaseSyncConnection if TYPE_CHECKING: from collections.abc import Callable, Coroutine from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] P = ParamSpec("P") T = TypeVar("T") def async_decorator(f: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]: """Wrap an async callable so it can be invoked from synchronous tests.""" def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: """Execute the wrapped coroutine function using ``asyncio.run``.""" return asyncio.run(f(*args, **kwargs)) return wrapper @final class SyncBufferConnection(BaseSyncConnection): """In-memory synchronous stream-style transport used by protocol tests.""" def __init__(self, incoming: bytes | bytearray = b"") -> None: """Initialize in-memory read/write buffers with optional incoming bytes.""" self.sent = Buffer() self.received = Buffer(incoming) @override def read(self, length: int, /) -> bytes: """Read ``length`` bytes from the queued incoming data.""" return self.received.read(length) @override def write(self, data: bytes | bytearray, /) -> None: """Append outgoing payload data to the send buffer.""" self.sent.write(data) def receive(self, data: bytes | bytearray) -> None: """Queue incoming payload data for subsequent reads.""" self.received.write(data) def remaining(self) -> int: """Return unread byte count from the incoming buffer.""" return self.received.remaining def flush(self) -> bytes: """Return and clear all bytes written to the send buffer.""" return self.sent.flush() @final class AsyncBufferConnection(BaseAsyncConnection): """In-memory asynchronous stream-style transport used by protocol tests.""" def __init__(self, incoming: bytes | bytearray = b"") -> None: """Initialize in-memory read/write buffers with optional incoming bytes.""" self.sent = Buffer() self.received = Buffer(incoming) @override async def read(self, length: int, /) -> bytes: """Read ``length`` bytes from the queued incoming data.""" return self.received.read(length) @override async def write(self, data: bytes | bytearray, /) -> None: """Append outgoing payload data to the send buffer.""" self.sent.write(data) def receive(self, data: bytes | bytearray) -> None: """Queue incoming payload data for subsequent reads.""" self.received.write(data) def remaining(self) -> int: """Return unread byte count from the incoming buffer.""" return self.received.remaining def flush(self) -> bytes: """Return and clear all bytes written to the send buffer.""" return self.sent.flush() @final class SyncDatagramConnection(BaseSyncConnection): """Datagram-like synchronous transport returning one queued packet per read.""" def __init__(self) -> None: self.sent = Buffer() self.received: list[bytes] = [] @override def read(self, _length: int, /) -> bytes: """Pop and return the next queued datagram payload.""" if not self.received: raise OSError("No datagram data to read.") return self.received.pop(0) @override def write(self, data: bytes | bytearray, /) -> None: """Append outgoing datagram payload to the send buffer.""" self.sent.write(data) def receive(self, data: bytes | bytearray) -> None: """Queue one incoming datagram payload for future reads.""" self.received.append(bytes(data)) def remaining(self) -> int: """Return size of the next queued datagram, or ``0`` when empty.""" if not self.received: return 0 return len(self.received[0]) def flush(self) -> bytes: """Return and clear all bytes written to the send buffer.""" return self.sent.flush() @final class AsyncDatagramConnection(BaseAsyncConnection): """Datagram-like asynchronous transport returning one queued packet per read.""" def __init__(self) -> None: self.sent = Buffer() self.received: list[bytes] = [] @override async def read(self, _length: int, /) -> bytes: """Pop and return the next queued datagram payload.""" if not self.received: raise OSError("No datagram data to read.") return self.received.pop(0) @override async def write(self, data: bytes | bytearray, /) -> None: """Append outgoing datagram payload to the send buffer.""" self.sent.write(data) def receive(self, data: bytes | bytearray) -> None: """Queue one incoming datagram payload for future reads.""" self.received.append(bytes(data)) def remaining(self) -> int: """Return size of the next queued datagram, or ``0`` when empty.""" if not self.received: return 0 return len(self.received[0]) def flush(self) -> bytes: """Return and clear all bytes written to the send buffer.""" return self.sent.flush() py-mine-mcstatus-341e7a4/tests/protocol/test_base_io.py000066400000000000000000000400721524603215100233020ustar00rootroot00000000000000from __future__ import annotations import struct from inspect import isawaitable from typing import TYPE_CHECKING, TypeVar, overload from unittest.mock import AsyncMock, Mock import pytest from mcstatus._protocol.io.base_io import INT_FORMATS_TYPE, StructFormat from mcstatus._protocol.io.buffer import Buffer from tests.protocol.helpers import AsyncBufferConnection, SyncBufferConnection if TYPE_CHECKING: from collections.abc import Awaitable ConnectionClass = type[SyncBufferConnection] | type[AsyncBufferConnection] T = TypeVar("T") @pytest.fixture( params=[ pytest.param(SyncBufferConnection, id="sync"), pytest.param(AsyncBufferConnection, id="async"), ] ) def conn_cls(request: pytest.FixtureRequest) -> ConnectionClass: """Provide a parametrized sync/async connection class for each test.""" return request.param @overload async def maybe_await(value: Awaitable[T], /) -> T: ... @overload async def maybe_await(value: T, /) -> T: ... async def maybe_await(value: Awaitable[T] | T, /) -> T: """Return a value directly or await it when needed. This keeps test calls explicit and type-checkable for both sync and async IO APIs. """ if isawaitable(value): return await value return value @pytest.mark.parametrize( ("fmt", "value", "expected"), [ pytest.param(StructFormat.UBYTE, 0, b"\x00", id="ubyte-0"), pytest.param(StructFormat.UBYTE, 15, b"\x0f", id="ubyte-15"), pytest.param(StructFormat.UBYTE, 255, b"\xff", id="ubyte-max"), pytest.param(StructFormat.BYTE, 0, b"\x00", id="byte-0"), pytest.param(StructFormat.BYTE, 15, b"\x0f", id="byte-15"), pytest.param(StructFormat.BYTE, 127, b"\x7f", id="byte-max"), pytest.param(StructFormat.BYTE, -20, b"\xec", id="byte-neg-20"), pytest.param(StructFormat.BYTE, -128, b"\x80", id="byte-min"), ], ) async def test_write_value_matches_reference( conn_cls: ConnectionClass, fmt: INT_FORMATS_TYPE, value: int, expected: bytes, ): conn = conn_cls() _ = await maybe_await(conn.write_value(fmt, value)) assert conn.flush() == expected async def test_write_value_char_uses_single_byte(conn_cls: ConnectionClass): conn = conn_cls() _ = await maybe_await(conn.write_value(StructFormat.CHAR, b"a")) assert conn.flush() == b"a" async def test_write_value_char_rejects_non_single_byte(conn_cls: ConnectionClass): conn = conn_cls() with pytest.raises(struct.error): _ = await maybe_await(conn.write_value(StructFormat.CHAR, b"ab")) @pytest.mark.parametrize( ("fmt", "value"), [ pytest.param(StructFormat.UBYTE, -1, id="ubyte-neg"), pytest.param(StructFormat.UBYTE, 256, id="ubyte-overflow"), pytest.param(StructFormat.BYTE, -129, id="byte-underflow"), pytest.param(StructFormat.BYTE, 128, id="byte-overflow"), ], ) async def test_write_value_rejects_out_of_range(conn_cls: ConnectionClass, fmt: INT_FORMATS_TYPE, value: int): conn = conn_cls() with pytest.raises(struct.error): _ = await maybe_await(conn.write_value(fmt, value)) @pytest.mark.parametrize( ("encoded", "fmt", "expected"), [ pytest.param(b"\x00", StructFormat.UBYTE, 0, id="ubyte-0"), pytest.param(b"\x0f", StructFormat.UBYTE, 15, id="ubyte-15"), pytest.param(b"\xff", StructFormat.UBYTE, 255, id="ubyte-max"), pytest.param(b"\x00", StructFormat.BYTE, 0, id="byte-0"), pytest.param(b"\x0f", StructFormat.BYTE, 15, id="byte-15"), pytest.param(b"\x7f", StructFormat.BYTE, 127, id="byte-max"), pytest.param(b"\xec", StructFormat.BYTE, -20, id="byte-neg-20"), pytest.param(b"\x80", StructFormat.BYTE, -128, id="byte-min"), ], ) async def test_read_value_matches_reference( conn_cls: ConnectionClass, encoded: bytes, fmt: INT_FORMATS_TYPE, expected: int, ): conn = conn_cls(encoded) assert await maybe_await(conn.read_value(fmt)) == expected async def test_read_value_char_returns_bytes(conn_cls: ConnectionClass): conn = conn_cls(b"a") value = await maybe_await(conn.read_value(StructFormat.CHAR)) assert value == b"a" assert isinstance(value, bytes) @pytest.mark.parametrize( ("number", "expected"), [ pytest.param(0, b"\x00", id="0"), pytest.param(127, b"\x7f", id="127"), pytest.param(128, b"\x80\x01", id="128"), pytest.param(255, b"\xff\x01", id="255"), pytest.param(1_000_000, b"\xc0\x84\x3d", id="1m"), pytest.param((2**31) - 1, b"\xff\xff\xff\xff\x07", id="max-32"), ], ) async def test_write_varuint_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes): conn = conn_cls() _ = await maybe_await(conn._write_varuint(number)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"\x00", 0, id="0"), pytest.param(b"\x7f", 127, id="127"), pytest.param(b"\x80\x01", 128, id="128"), pytest.param(b"\xff\x01", 255, id="255"), pytest.param(b"\xc0\x84\x3d", 1_000_000, id="1m"), pytest.param(b"\xff\xff\xff\xff\x07", (2**31) - 1, id="max-32"), ], ) async def test_read_varuint_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int): conn = conn_cls(encoded) assert await maybe_await(conn._read_varuint()) == expected @pytest.mark.parametrize( ("number", "max_bits"), [ (-1, 128), (-1, 1), (2**16, 16), (2**32, 32), ], ) async def test_write_varuint_rejects_out_of_range(conn_cls: ConnectionClass, number: int, max_bits: int): conn = conn_cls() with pytest.raises(ValueError, match=r"outside of the range of"): _ = await maybe_await(conn._write_varuint(number, max_bits=max_bits)) @pytest.mark.parametrize( ("encoded", "max_bits"), [ (b"\x80\x80\x04", 16), (b"\x80\x80\x80\x80\x10", 32), ], ) async def test_read_varuint_rejects_out_of_range(conn_cls: ConnectionClass, encoded: bytes, max_bits: int): conn = conn_cls(encoded) with pytest.raises(OSError, match=r"outside the range of"): _ = await maybe_await(conn._read_varuint(max_bits=max_bits)) @pytest.mark.parametrize( ("encoded", "max_bits", "max_bytes"), [ (b"\x80\x80\x80\x00", 16, 3), (b"\x80\x80\x80\x80\x80\x00", 32, 5), ], ) async def test_read_varuint_rejects_too_many_bytes( conn_cls: ConnectionClass, encoded: bytes, max_bits: int, max_bytes: int, ): conn = conn_cls(encoded) with pytest.raises( OSError, match=rf"^Received varint had too many bytes for {max_bits}-bit int \(continuation bit set on byte {max_bytes}\)\.$", ): _ = await maybe_await(conn._read_varuint(max_bits=max_bits)) @pytest.mark.parametrize( ("number", "expected"), [ pytest.param(127, b"\x7f", id="127"), pytest.param(16_384, b"\x80\x80\x01", id="16384"), pytest.param(-128, b"\x80\xff\xff\xff\x0f", id="-128"), pytest.param(-16_383, b"\x81\x80\xff\xff\x0f", id="-16383"), ], ) async def test_write_varint_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes): conn = conn_cls() _ = await maybe_await(conn.write_varint(number)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"\x7f", 127, id="127"), pytest.param(b"\x80\x80\x01", 16_384, id="16384"), pytest.param(b"\x80\xff\xff\xff\x0f", -128, id="-128"), pytest.param(b"\x81\x80\xff\xff\x0f", -16_383, id="-16383"), ], ) async def test_read_varint_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int): conn = conn_cls(encoded) assert await maybe_await(conn.read_varint()) == expected @pytest.mark.parametrize( ("number", "expected"), [ pytest.param(127, b"\x7f", id="127"), pytest.param(16_384, b"\x80\x80\x01", id="16384"), pytest.param(-128, b"\x80\xff\xff\xff\xff\xff\xff\xff\xff\x01", id="-128"), pytest.param(-16_383, b"\x81\x80\xff\xff\xff\xff\xff\xff\xff\x01", id="-16383"), ], ) async def test_write_varlong_matches_reference(conn_cls: ConnectionClass, number: int, expected: bytes): conn = conn_cls() _ = await maybe_await(conn.write_varlong(number)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"\x7f", 127, id="127"), pytest.param(b"\x80\x80\x01", 16_384, id="16384"), pytest.param(b"\x80\xff\xff\xff\xff\xff\xff\xff\xff\x01", -128, id="-128"), pytest.param(b"\x81\x80\xff\xff\xff\xff\xff\xff\xff\x01", -16_383, id="-16383"), ], ) async def test_read_varlong_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: int): conn = conn_cls(encoded) assert await maybe_await(conn.read_varlong()) == expected @pytest.mark.parametrize("number", [0, 1, 127, 16_384, -1, -(2**31), (2**31) - 1]) async def test_varint_roundtrip(conn_cls: ConnectionClass, number: int): conn = conn_cls() _ = await maybe_await(conn.write_varint(number)) conn.receive(conn.flush()) assert await maybe_await(conn.read_varint()) == number @pytest.mark.parametrize("number", [127, 16_384, -128, -16_383, -(2**63), (2**63) - 1]) async def test_varlong_roundtrip(conn_cls: ConnectionClass, number: int): conn = conn_cls() _ = await maybe_await(conn.write_varlong(number)) conn.receive(conn.flush()) assert await maybe_await(conn.read_varlong()) == number @pytest.mark.parametrize("number", [-(2**63) - 1, 2**63]) async def test_write_varlong_rejects_out_of_range(conn_cls: ConnectionClass, number: int): conn = conn_cls() with pytest.raises(ValueError, match=r"out of range"): _ = await maybe_await(conn.write_varlong(number)) async def test_optional_helpers(conn_cls: ConnectionClass): conn = conn_cls() if isinstance(conn, AsyncBufferConnection): writer = AsyncMock(return_value="written") assert await conn.write_optional(None, writer) is None writer.assert_not_awaited() assert conn.flush() == b"\x00" assert await conn.write_optional("value", writer) == "written" writer.assert_awaited_once_with("value") assert conn.flush() == b"\x01" reader = AsyncMock(return_value="parsed") conn.receive(b"\x00") assert await conn.read_optional(reader) is None reader.assert_not_awaited() conn.receive(b"\x01") assert await conn.read_optional(reader) == "parsed" reader.assert_awaited_once_with() return writer = Mock(return_value="written") assert conn.write_optional(None, writer) is None writer.assert_not_called() assert conn.flush() == b"\x00" assert conn.write_optional("value", writer) == "written" writer.assert_called_once_with("value") assert conn.flush() == b"\x01" reader = Mock(return_value="parsed") conn.receive(b"\x00") assert conn.read_optional(reader) is None reader.assert_not_called() conn.receive(b"\x01") assert conn.read_optional(reader) == "parsed" reader.assert_called_once_with() async def test_write_and_read_ascii(conn_cls: ConnectionClass): conn = conn_cls() _ = await maybe_await(conn.write_ascii("hello")) conn.receive(conn.flush()) assert await maybe_await(conn.read_ascii()) == "hello" async def test_write_and_read_bytearray(conn_cls: ConnectionClass): conn = conn_cls() data = b"\x00\x01hello\xff" _ = await maybe_await(conn.write_bytearray(data)) conn.receive(conn.flush()) assert await maybe_await(conn.read_bytearray()) == data @pytest.mark.parametrize( ("data", "expected"), [ pytest.param(b"", b"\x00", id="empty"), pytest.param(b"\x01", b"\x01\x01", id="single"), pytest.param(b"hello\x00world", b"\x0bhello\x00world", id="with-null"), pytest.param(b"\x01\x02\x03four\x05", b"\x08\x01\x02\x03four\x05", id="mixed"), ], ) async def test_write_bytearray_matches_reference(conn_cls: ConnectionClass, data: bytes, expected: bytes): conn = conn_cls() _ = await maybe_await(conn.write_bytearray(data)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"\x00", b"", id="empty"), pytest.param(b"\x01\x01", b"\x01", id="single"), pytest.param(b"\x0bhello\x00world", b"hello\x00world", id="with-null"), pytest.param(b"\x08\x01\x02\x03four\x05", b"\x01\x02\x03four\x05", id="mixed"), ], ) async def test_read_bytearray_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: bytes): conn = conn_cls(encoded) assert await maybe_await(conn.read_bytearray()) == expected async def test_read_bytearray_rejects_negative_length(conn_cls: ConnectionClass): conn = conn_cls(b"\xff\xff\xff\xff\x0f") with pytest.raises(OSError, match=r"^Length prefix for byte arrays must be non-negative, got -1\.$"): _ = await maybe_await(conn.read_bytearray()) @pytest.mark.parametrize( ("value", "expected"), [ pytest.param("test", b"test\x00", id="test"), pytest.param("a" * 100, b"a" * 100 + b"\x00", id="100-as"), pytest.param("", b"\x00", id="empty"), ], ) async def test_write_ascii_matches_reference(conn_cls: ConnectionClass, value: str, expected: bytes): conn = conn_cls() _ = await maybe_await(conn.write_ascii(value)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"test\x00", "test", id="test"), pytest.param(b"a" * 100 + b"\x00", "a" * 100, id="100-as"), pytest.param(b"\x00", "", id="empty"), ], ) async def test_read_ascii_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: str): conn = conn_cls(encoded) assert await maybe_await(conn.read_ascii()) == expected @pytest.mark.parametrize( ("value", "expected"), [ pytest.param("test", b"\x04test", id="test"), pytest.param("a" * 100, b"\x64" + b"a" * 100, id="100-as"), pytest.param("", b"\x00", id="empty"), pytest.param("नमस्ते", b"\x12" + "नमस्ते".encode(), id="hindi"), ], ) async def test_write_utf_matches_reference(conn_cls: ConnectionClass, value: str, expected: bytes): conn = conn_cls() _ = await maybe_await(conn.write_utf(value)) assert conn.flush() == expected @pytest.mark.parametrize( ("encoded", "expected"), [ pytest.param(b"\x04test", "test", id="test"), pytest.param(b"\x64" + b"a" * 100, "a" * 100, id="100-as"), pytest.param(b"\x00", "", id="empty"), pytest.param(b"\x12" + "नमस्ते".encode(), "नमस्ते", id="hindi"), ], ) async def test_read_utf_matches_reference(conn_cls: ConnectionClass, encoded: bytes, expected: str): conn = conn_cls(encoded) assert await maybe_await(conn.read_utf()) == expected async def test_write_utf_rejects_too_many_characters(conn_cls: ConnectionClass): conn = conn_cls() with pytest.raises(ValueError, match=r"Maximum character limit for writing strings is 32767 characters"): _ = await maybe_await(conn.write_utf("a" * 32768)) async def test_read_utf_rejects_too_many_bytes(conn_cls: ConnectionClass): payload = Buffer() payload.write_varint(131069) conn = conn_cls(payload) with pytest.raises(OSError, match=r"Maximum read limit for utf strings is 131068 bytes, got 131069"): _ = await maybe_await(conn.read_utf()) async def test_read_utf_rejects_negative_length(conn_cls: ConnectionClass): conn = conn_cls(b"\xff\xff\xff\xff\x0f") with pytest.raises(OSError, match=r"^Length prefix for utf strings must be non-negative, got -1\.$"): _ = await maybe_await(conn.read_utf()) async def test_read_utf_rejects_too_many_characters(conn_cls: ConnectionClass): text = "a" * 32768 payload = Buffer() payload.write_varint(len(text.encode("utf-8"))) payload.write(text.encode("utf-8")) conn = conn_cls(payload) with pytest.raises(OSError, match=r"Maximum read limit for utf strings is 32767 characters, got 32768"): _ = await maybe_await(conn.read_utf()) py-mine-mcstatus-341e7a4/tests/protocol/test_base_io_twos_complement.py000066400000000000000000000037561524603215100266110ustar00rootroot00000000000000from __future__ import annotations import pytest from mcstatus._protocol.io.base_io import from_twos_complement, to_twos_complement TWOS_COMPLEMENT_CASES = [ (-128, 8, 0x80), (-2, 8, 0xFE), (-1, 8, 0xFF), (0, 8, 0x00), (1, 8, 0x01), (127, 8, 0x7F), (-(2**15), 16, 0x8000), (-1, 16, 0xFFFF), ((2**15) - 1, 16, 0x7FFF), (-(2**31), 32, 0x80000000), (-1, 32, 0xFFFFFFFF), ((2**31) - 1, 32, 0x7FFFFFFF), (-(2**63), 64, 0x8000000000000000), (-9_876_543_210_123_456, 64, 0xFFDCE956165A0F40), (-1, 64, 0xFFFFFFFFFFFFFFFF), (0, 64, 0x0000000000000000), (9_876_543_210_123_456, 64, 0x002316A9E9A5F0C0), ((2**63) - 1, 64, 0x7FFFFFFFFFFFFFFF), ] @pytest.mark.parametrize( ("number", "bits", "expected_twos"), TWOS_COMPLEMENT_CASES, ) def test_to_twos_complement_matches_expected_values(number: int, bits: int, expected_twos: int): assert to_twos_complement(number, bits=bits) == expected_twos @pytest.mark.parametrize( ("twos_value", "bits", "expected_number"), [(twos_value, bits, number) for number, bits, twos_value in TWOS_COMPLEMENT_CASES], ) def test_from_twos_complement_matches_expected_values(twos_value: int, bits: int, expected_number: int): assert from_twos_complement(twos_value, bits=bits) == expected_number @pytest.mark.parametrize( ("number", "bits"), [ (-129, 8), (128, 8), (-(2**31) - 1, 32), (2**31, 32), (-(2**63) - 1, 64), (2**63, 64), ], ) def test_to_twos_complement_rejects_out_of_range(number: int, bits: int): with pytest.raises(ValueError, match=r"out of range"): _ = to_twos_complement(number, bits=bits) @pytest.mark.parametrize( ("number", "bits"), [ (-1, 8), (256, 8), (2**32, 32), (2**64, 64), ], ) def test_from_twos_complement_rejects_out_of_range(number: int, bits: int): with pytest.raises(ValueError, match=r"out of range"): _ = from_twos_complement(number, bits=bits) py-mine-mcstatus-341e7a4/tests/protocol/test_bedrock_client.py000066400000000000000000000043661524603215100246560ustar00rootroot00000000000000import sys import time from unittest import mock import pytest from mcstatus._net.address import Address from mcstatus._protocol.bedrock_client import BedrockClient from mcstatus.responses import BedrockStatusResponse def test_bedrock_response_is_expected_type(): data = ( b"\x1c\x00\x00\x00\x00\x00\x00\x00\x004GT\x00\xb8\x83D\xde\x00\xff\xff\x00\xfe\xfe\xfe\xfe\xfd\xfd\xfd\xfd" b"\x124Vx\x00wMCPE;\xc2\xa7r\xc2\xa74G\xc2\xa7r\xc2\xa76a\xc2\xa7r\xc2\xa7ey\xc2\xa7r\xc2\xa72B\xc2\xa7r\xc2" b"\xa71o\xc2\xa7r\xc2\xa79w\xc2\xa7r\xc2\xa7ds\xc2\xa7r\xc2\xa74e\xc2\xa7r\xc2\xa76r;422;;1;69;376707197539105" b"3022;;Default;1;19132;-1;" ) parsed = BedrockClient.parse_response(data, 1) assert isinstance(parsed, BedrockStatusResponse) @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) def test_latency_is_real_number(): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_status(): time.sleep(0.001) return mock.DEFAULT bedrock_client = BedrockClient(Address("localhost", 25565)) with ( mock.patch.object(bedrock_client, "_read_status") as mocked_read, mock.patch.object(bedrock_client, "parse_response") as mocked_parse_response, ): mocked_read.side_effect = mocked_read_status _ = bedrock_client.read_status() # we slept 1ms, so this should be always ~1. assert mocked_parse_response.call_args[0][1] >= 1 @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) async def test_async_latency_is_real_number(): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_status(): time.sleep(0.001) return mock.DEFAULT bedrock_client = BedrockClient(Address("localhost", 25565)) with ( mock.patch.object(bedrock_client, "_read_status_async") as mocked_read, mock.patch.object(bedrock_client, "parse_response") as mocked_parse_response, ): mocked_read.side_effect = mocked_read_status _ = await bedrock_client.read_status_async() # we slept 1ms, so this should be always ~1. assert mocked_parse_response.call_args[0][1] >= 1 py-mine-mcstatus-341e7a4/tests/protocol/test_connection.py000066400000000000000000000300131524603215100240320ustar00rootroot00000000000000from collections.abc import Iterator from typing import cast, final from unittest.mock import Mock, patch import pytest from mcstatus._net.address import Address from mcstatus._protocol.io.base_io import StructFormat from mcstatus._protocol.io.buffer import Buffer from mcstatus._protocol.io.connection import TCPSocketConnection, UDPSocketConnection @final class TestBuffer: def setup_method(self): self.connection = Buffer() def test_flush(self): self.connection.write(bytearray.fromhex("7FAABB")) assert self.connection.flush() == bytearray.fromhex("7FAABB") assert self.connection == bytearray() def test_remaining(self): self.connection.write(bytearray.fromhex("7FAABB")) assert self.connection.remaining == 3 def test_reset(self): self.connection.write(b"abcdef") assert self.connection.read(3) == b"abc" self.connection.reset() assert self.connection.read(6) == b"abcdef" def test_clear_only_already_read(self): self.connection.write(b"abcdef") assert self.connection.read(2) == b"ab" self.connection.clear(only_already_read=True) assert self.connection == bytearray(b"cdef") assert self.connection.remaining == 4 def test_unread_view(self): self.connection.write(b"abcdef") assert self.connection.read(2) == b"ab" assert bytes(self.connection.unread_view()) == b"cdef" def test_flush_only_returns_unread_data(self): self.connection.write(b"abcdef") assert self.connection.read(2) == b"ab" assert self.connection.flush() == b"cdef" assert self.connection == bytearray() def test_send(self): self.connection.write(bytearray.fromhex("7F")) self.connection.write(bytearray.fromhex("AABB")) assert self.connection.flush() == bytearray.fromhex("7FAABB") def test_read(self): self.connection.write(bytearray.fromhex("7FAABB")) assert self.connection.read(2) == bytearray.fromhex("7FAA") assert self.connection.read(1) == bytearray.fromhex("BB") def _assert_varint_read_write(self, hexstr: str, value: int) -> None: self.connection.write(bytearray.fromhex(hexstr)) assert self.connection.read_varint() == value self.connection.write_varint(value) assert self.connection.flush() == bytearray.fromhex(hexstr) def test_varint_cases(self): self._assert_varint_read_write("00", 0) self._assert_varint_read_write("01", 1) self._assert_varint_read_write("0F", 15) self._assert_varint_read_write("FFFFFFFF07", 2147483647) self._assert_varint_read_write("FFFFFFFF0F", -1) self._assert_varint_read_write("8080808008", -2147483648) def test_read_invalid_varint(self): self.connection.write(bytearray.fromhex("FFFFFFFF10")) with pytest.raises(IOError, match=r"^Received varint was outside the range of 32-bit int.$"): _ = self.connection.read_varint() def test_write_invalid_varint(self): with pytest.raises( ValueError, match=r"^Can't convert number 2147483648 into 32-bit twos complement format - out of range$", ): self.connection.write_varint(2147483648) with pytest.raises( ValueError, match=r"^Can't convert number -2147483649 into 32-bit twos complement format - out of range$", ): self.connection.write_varint(-2147483649) def test_read_utf(self): self.connection.write(bytearray.fromhex("0D48656C6C6F2C20776F726C6421")) assert self.connection.read_utf() == "Hello, world!" def test_write_utf(self): self.connection.write_utf("Hello, world!") assert self.connection.flush() == bytearray.fromhex("0D48656C6C6F2C20776F726C6421") def test_read_empty_utf(self): self.connection.write_utf("") assert self.connection.flush() == bytearray.fromhex("00") def test_read_ascii(self): self.connection.write(bytearray.fromhex("48656C6C6F2C20776F726C642100")) assert self.connection.read_ascii() == "Hello, world!" def test_write_ascii(self): self.connection.write_ascii("Hello, world!") assert self.connection.flush() == bytearray.fromhex("48656C6C6F2C20776F726C642100") def test_read_empty_ascii(self): self.connection.write_ascii("") assert self.connection.flush() == bytearray.fromhex("00") def test_read_short_negative(self): self.connection.write(bytearray.fromhex("8000")) assert self.connection.read_value(StructFormat.SHORT) == -32768 def test_write_short_negative(self): self.connection.write_value(StructFormat.SHORT, -32768) assert self.connection.flush() == bytearray.fromhex("8000") def test_read_short_positive(self): self.connection.write(bytearray.fromhex("7FFF")) assert self.connection.read_value(StructFormat.SHORT) == 32767 def test_write_short_positive(self): self.connection.write_value(StructFormat.SHORT, 32767) assert self.connection.flush() == bytearray.fromhex("7FFF") def test_read_ushort_positive(self): self.connection.write(bytearray.fromhex("8000")) assert self.connection.read_value(StructFormat.USHORT) == 32768 def test_write_ushort_positive(self): self.connection.write_value(StructFormat.USHORT, 32768) assert self.connection.flush() == bytearray.fromhex("8000") def test_read_int_negative(self): self.connection.write(bytearray.fromhex("80000000")) assert self.connection.read_value(StructFormat.INT) == -2147483648 def test_write_int_negative(self): self.connection.write_value(StructFormat.INT, -2147483648) assert self.connection.flush() == bytearray.fromhex("80000000") def test_read_int_positive(self): self.connection.write(bytearray.fromhex("7FFFFFFF")) assert self.connection.read_value(StructFormat.INT) == 2147483647 def test_write_int_positive(self): self.connection.write_value(StructFormat.INT, 2147483647) assert self.connection.flush() == bytearray.fromhex("7FFFFFFF") def test_read_uint_positive(self): self.connection.write(bytearray.fromhex("80000000")) assert self.connection.read_value(StructFormat.UINT) == 2147483648 def test_write_uint_positive(self): self.connection.write_value(StructFormat.UINT, 2147483648) assert self.connection.flush() == bytearray.fromhex("80000000") def test_read_long_negative(self): self.connection.write(bytearray.fromhex("8000000000000000")) assert self.connection.read_value(StructFormat.LONGLONG) == -9223372036854775808 def test_write_long_negative(self): self.connection.write_value(StructFormat.LONGLONG, -9223372036854775808) assert self.connection.flush() == bytearray.fromhex("8000000000000000") def test_read_long_positive(self): self.connection.write(bytearray.fromhex("7FFFFFFFFFFFFFFF")) assert self.connection.read_value(StructFormat.LONGLONG) == 9223372036854775807 def test_write_long_positive(self): self.connection.write_value(StructFormat.LONGLONG, 9223372036854775807) assert self.connection.flush() == bytearray.fromhex("7FFFFFFFFFFFFFFF") def test_read_ulong_positive(self): self.connection.write(bytearray.fromhex("8000000000000000")) assert self.connection.read_value(StructFormat.ULONGLONG) == 9223372036854775808 def test_write_ulong_positive(self): self.connection.write_value(StructFormat.ULONGLONG, 9223372036854775808) assert self.connection.flush() == bytearray.fromhex("8000000000000000") @pytest.mark.parametrize(("as_bytes", "as_bool"), [("01", True), ("00", False)]) def test_read_bool(self, as_bytes: str, as_bool: bool) -> None: self.connection.write(bytearray.fromhex(as_bytes)) assert self.connection.read_value(StructFormat.BOOL) is as_bool @pytest.mark.parametrize(("as_bytes", "as_bool"), [("01", True), ("00", False)]) def test_write_bool(self, as_bytes: str, as_bool: bool) -> None: self.connection.write_value(StructFormat.BOOL, as_bool) assert self.connection.flush() == bytearray.fromhex(as_bytes) def test_read_bytearray(self): self.connection.write(bytearray.fromhex("027FAA")) assert self.connection.read_bytearray() == bytearray.fromhex("7FAA") def test_write_bytearray(self): self.connection.write_bytearray(bytearray.fromhex("7FAA")) assert self.connection.flush() == bytearray.fromhex("027FAA") def test_read_empty(self): with pytest.raises( IOError, match=r"^Requested to read more data than available. Read 0 bytes: bytearray\(b''\), out of 1 requested bytes.$", ): _ = self.connection.read(1) def test_read_not_enough(self): self.connection.write(bytearray(b"a")) with pytest.raises( IOError, match=r"^Requested to read more data than available. Read 1 bytes: bytearray\(b'a'\), out of 2 requested bytes.$", ): _ = self.connection.read(2) def test_read_negative_length(self): with pytest.raises(IOError, match=r"^Requested to read a negative amount of data: -1\.$"): _ = self.connection.read(-1) class TestTCPSocketConnection: @pytest.fixture(scope="class") @staticmethod def connection() -> Iterator[TCPSocketConnection]: test_addr = Address("localhost", 1234) socket = Mock() socket.recv = Mock() socket.sendall = Mock() with patch("socket.create_connection") as create_connection: create_connection.return_value = socket with TCPSocketConnection(test_addr) as connection: yield connection def test_read(self, connection: TCPSocketConnection): cast("Mock", connection.socket.recv).return_value = bytearray.fromhex("7FAA") assert connection.read(2) == bytearray.fromhex("7FAA") def test_read_empty(self, connection: TCPSocketConnection): cast("Mock", connection.socket.recv).return_value = bytearray() with pytest.raises(IOError, match=r"^Server did not respond with any information!$"): _ = connection.read(1) def test_read_not_enough(self, connection: TCPSocketConnection): cast("Mock", connection.socket.recv).side_effect = [bytearray(b"a"), bytearray()] with pytest.raises(IOError, match=r"^Server did not respond with any information!$"): _ = connection.read(2) def test_write(self, connection: TCPSocketConnection): connection.write(bytearray.fromhex("7FAA")) cast("Mock", connection.socket.sendall).assert_called_once_with(bytearray.fromhex("7FAA")) class TestUDPSocketConnection: @pytest.fixture(scope="class") @staticmethod def connection() -> Iterator[UDPSocketConnection]: test_addr = Address("127.0.0.1", 1234) socket = Mock() socket.recvfrom = Mock() socket.sendto = Mock() with patch("socket.socket") as create_socket: create_socket.return_value = socket with UDPSocketConnection(test_addr) as connection: yield connection def test_remaining(self, connection: UDPSocketConnection): assert connection.remaining == 65535 def test_read(self, connection: UDPSocketConnection): cast("Mock", connection.socket.recvfrom).return_value = [bytearray.fromhex("7FAA")] assert connection.read(2) == bytearray.fromhex("7FAA") def test_read_empty(self, connection: UDPSocketConnection): cast("Mock", connection.socket.recvfrom).return_value = [] with pytest.raises(IndexError, match=r"^list index out of range$"): _ = connection.read(1) def test_write(self, connection: UDPSocketConnection): connection.write(bytearray.fromhex("7FAA")) cast("Mock", connection.socket.sendto).assert_called_once_with( bytearray.fromhex("7FAA"), Address("127.0.0.1", 1234), ) py-mine-mcstatus-341e7a4/tests/protocol/test_java_client.py000066400000000000000000000125101524603215100241540ustar00rootroot00000000000000import sys import time from typing import final from unittest import mock import pytest from mcstatus._net.address import Address from mcstatus._protocol.java_client import JavaClient from tests.protocol.helpers import SyncBufferConnection @final class TestJavaClient: def setup_method(self): self.connection = SyncBufferConnection() self.java_client = JavaClient( self.connection, # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) def test_handshake(self): self.java_client.handshake() assert self.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01") def test_read_status(self): self.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2" "26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22" "70726F746F636F6C223A34347D7D" ) ) status = self.java_client.read_status() assert status.raw == { "description": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, } assert self.connection.flush() == bytearray.fromhex("0100") def test_read_status_invalid_json(self): self.connection.receive(bytearray.fromhex("0300017B")) with pytest.raises(IOError, match=r"^Received invalid JSON$"): _ = self.java_client.read_status() def test_read_status_invalid_reply(self): self.connection.receive( # no motd, see also #922 bytearray.fromhex( "4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616" "D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D" ) ) _ = self.java_client.read_status() def test_read_status_invalid_status(self): self.connection.receive(bytearray.fromhex("0105")) with pytest.raises(IOError, match=r"^Received invalid status response packet.$"): _ = self.java_client.read_status() def test_test_ping(self): self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 14515484 assert self.java_client.test_ping() >= 0 assert self.connection.flush() == bytearray.fromhex("09010000000000DD7D1C") def test_test_ping_invalid(self): self.connection.receive(bytearray.fromhex("011F")) self.java_client.ping_token = 14515484 with pytest.raises(IOError, match=r"^Received invalid ping response packet.$"): _ = self.java_client.test_ping() def test_test_ping_wrong_token(self): self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 12345 with pytest.raises(IOError, match=r"^Received mangled ping response \(expected token 12345, got 14515484\)$"): _ = self.java_client.test_ping() # Windows CI can occasionally measure <1ms despite a 1ms sleep; # see https://github.com/py-mine/mcstatus/issues/442. @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) def test_latency_is_real_number(self): self.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2" "26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22" "70726F746F636F6C223A34347D7D" ) ) def mocked_read_bytearray() -> bytes: time.sleep(0.001) return original_read_bytearray() original_read_bytearray = self.connection.read_bytearray with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray): # Latency should be in milliseconds, so somewhere just above 1 # # We give it a pretty big leeway with the max here, as the MacOS CI runs can # sometimes take quite long (upwards of 10s). latency = self.java_client.read_status().latency assert 1 <= latency <= 20 # Windows CI can occasionally measure <1ms despite a 1ms sleep; # see https://github.com/py-mine/mcstatus/issues/442. @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) def test_test_ping_is_in_milliseconds(self): self.java_client.ping_token = 14515484 self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) def mocked_read_bytearray() -> bytes: time.sleep(0.001) return original_read_bytearray() original_read_bytearray = self.connection.read_bytearray with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray): # Latency should be in milliseconds, so somewhere just above 1 # # We give it a pretty big leeway with the max here, as the MacOS CI runs can # sometimes take quite long (upwards of 10s). latency = self.java_client.test_ping() assert 1 <= latency <= 20 py-mine-mcstatus-341e7a4/tests/protocol/test_java_client_async.py000066400000000000000000000133301524603215100253520ustar00rootroot00000000000000import asyncio import sys from typing import final from unittest import mock import pytest from mcstatus._net.address import Address from mcstatus._protocol.java_client import AsyncJavaClient from tests.protocol.helpers import AsyncBufferConnection, async_decorator @final class TestAsyncJavaClient: def setup_method(self): self.connection = AsyncBufferConnection() self.java_client = AsyncJavaClient( self.connection, # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) def test_handshake(self): async_decorator(self.java_client.handshake)() assert self.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01") def test_read_status(self): self.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2" "26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22" "70726F746F636F6C223A34347D7D" ) ) status = async_decorator(self.java_client.read_status)() assert status.raw == { "description": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, } assert self.connection.flush() == bytearray.fromhex("0100") def test_read_status_invalid_json(self): self.connection.receive(bytearray.fromhex("0300017B")) with pytest.raises(IOError, match=r"^Received invalid JSON$"): _ = async_decorator(self.java_client.read_status)() def test_read_status_invalid_reply(self): self.connection.receive( bytearray.fromhex( "4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616" "D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D" ) ) _ = async_decorator(self.java_client.read_status)() def test_read_status_invalid_status(self): self.connection.receive(bytearray.fromhex("0105")) with pytest.raises(IOError, match=r"^Received invalid status response packet.$"): _ = async_decorator(self.java_client.read_status)() def test_test_ping(self): self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 14515484 assert async_decorator(self.java_client.test_ping)() >= 0 assert self.connection.flush() == bytearray.fromhex("09010000000000DD7D1C") def test_test_ping_invalid(self): self.connection.receive(bytearray.fromhex("011F")) self.java_client.ping_token = 14515484 with pytest.raises(IOError, match=r"^Received invalid ping response packet.$"): _ = async_decorator(self.java_client.test_ping)() def test_test_ping_wrong_token(self): self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 12345 with pytest.raises(IOError, match=r"^Received mangled ping response \(expected token 12345, got 14515484\)$"): _ = async_decorator(self.java_client.test_ping)() # Windows CI can occasionally measure <1ms despite a 1ms sleep; # see https://github.com/py-mine/mcstatus/issues/442. @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) async def test_latency_is_real_number(self): self.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2" "26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22" "70726F746F636F6C223A34347D7D" ) ) async def mocked_read_bytearray() -> bytes: """Delay reads while still delegating to the real async connection implementation.""" await asyncio.sleep(0.001) return await original_read_bytearray() original_read_bytearray = self.connection.read_bytearray with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray): # Latency should be in milliseconds, so somewhere just above 1 # # We give it a pretty big leeway with the max here, as the MacOS CI runs can # sometimes take quite long (upwards of 10s). latency = (await self.java_client.read_status()).latency assert 1 <= latency <= 20 # Windows CI can occasionally measure <1ms despite a 1ms sleep; # see https://github.com/py-mine/mcstatus/issues/442. @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) async def test_test_ping_is_in_milliseconds(self): self.java_client.ping_token = 14515484 self.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) async def mocked_read_bytearray() -> bytes: """Delay reads while still delegating to the real async connection implementation.""" await asyncio.sleep(0.001) return await original_read_bytearray() original_read_bytearray = self.connection.read_bytearray with mock.patch.object(self.connection, "read_bytearray", side_effect=mocked_read_bytearray): # Latency should be in milliseconds, so somewhere just above 1 # # We give it a pretty big leeway with the max here, as the MacOS CI runs can # sometimes take quite long (upwards of 10s). latency = await self.java_client.test_ping() assert 1 <= latency <= 20 py-mine-mcstatus-341e7a4/tests/protocol/test_legacy_client.py000066400000000000000000000032201524603215100244750ustar00rootroot00000000000000import pytest from mcstatus._protocol.legacy_client import LegacyClient from mcstatus.motd import Motd from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion from tests.protocol.helpers import SyncBufferConnection def test_invalid_kick_reason(): with pytest.raises(IOError, match=r"^Received invalid kick packet reason$"): _ = LegacyClient.parse_response("Invalid Reason".encode("UTF-16BE"), 123.0) @pytest.mark.parametrize( ("response", "expected"), [ ( "A Minecraft Server§0§20".encode("UTF-16BE"), LegacyStatusResponse( players=LegacyStatusPlayers(online=0, max=20), version=LegacyStatusVersion(name="<1.4", protocol=-1), motd=Motd.parse("A Minecraft Server"), latency=123.0, ), ), ( "§1\x0051\x001.4.7\x00A Minecraft Server\x000\x0020".encode("UTF-16BE"), LegacyStatusResponse( players=LegacyStatusPlayers(online=0, max=20), version=LegacyStatusVersion(name="1.4.7", protocol=51), motd=Motd.parse("A Minecraft Server"), latency=123.0, ), ), ], ids=["b1.8", "1.4.7"], ) def test_parse_response(response: bytes, expected: LegacyStatusResponse): assert LegacyClient.parse_response(response, 123.0) == expected def test_invalid_packet_id(): socket = SyncBufferConnection() socket.receive(bytearray.fromhex("00")) server = LegacyClient(socket) with pytest.raises(IOError, match=r"^Received invalid packet ID$"): _ = server.read_status() py-mine-mcstatus-341e7a4/tests/protocol/test_query_client.py000066400000000000000000000117171524603215100244100ustar00rootroot00000000000000from typing import final from unittest.mock import Mock from mcstatus._protocol.query_client import QueryClient from mcstatus.motd import Motd from tests.protocol.helpers import SyncDatagramConnection @final class TestQueryClient: def setup_method(self): self.connection = SyncDatagramConnection() self.query_client = QueryClient(self.connection) # pyright: ignore[reportArgumentType] def test_handshake(self): self.connection.receive(bytearray.fromhex("090000000035373033353037373800")) self.query_client.handshake() conn_bytes = self.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert self.query_client.challenge == 570350778 def test_query(self): self.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) response = self.query_client.read_query() conn_bytes = self.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD00") assert conn_bytes[7:] == bytearray.fromhex("0000000000000000") assert response.raw == { "hostname": "A Minecraft Server", "gametype": "SMP", "game_id": "MINECRAFT", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "25565", "hostip": "192.168.56.1", } assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"] def test_query_handles_unorderd_map_response(self): self.connection.receive( bytearray( b"\x00\x00\x00\x00\x00GeyserMC\x00\x80\x00hostname\x00Geyser\x00hostip\x001.1.1.1\x00plugins\x00\x00numplayers" b"\x001\x00gametype\x00SMP\x00maxplayers\x00100\x00hostport\x0019132\x00version\x00Geyser" b" (git-master-0fd903e) 1.18.10\x00map\x00Geyser\x00game_id\x00MINECRAFT\x00\x00\x01player_\x00\x00\x00" ) ) response = self.query_client.read_query() _ = self.connection.flush() assert response.raw["game_id"] == "MINECRAFT" assert response.motd == Motd.parse("Geyser") assert response.software.version == "Geyser (git-master-0fd903e) 1.18.10" def test_query_handles_unicode_motd_with_nulls(self): self.connection.receive( bytearray( b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00hostname\x00\x00*K\xd5\x00gametype\x00SMP" b"\x00game_id\x00MINECRAFT\x00version\x001.16.5\x00plugins\x00Paper on 1.16.5-R0.1-SNAPSHOT\x00map\x00world" b"\x00numplayers\x000\x00maxplayers\x0020\x00hostport\x0025565\x00hostip\x00127.0.1.1\x00\x00\x01player_\x00" b"\x00\x00" ) ) response = self.query_client.read_query() _ = self.connection.flush() assert response.raw["game_id"] == "MINECRAFT" assert response.motd == Motd.parse("\x00*KÕ") def test_query_handles_unicode_motd_with_2a00_at_the_start(self): self.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d6500006f746865720067616d657479706500534d500067616d655f6964004d" "494e4543524146540076657273696f6e00312e31382e3100706c7567696e7300006d617000776f726c64006e756d706c617965727300" "30006d6178706c617965727300323000686f7374706f727400323535363500686f73746970003137322e31372e302e32000001706c61" "7965725f000000" ) ) response = self.query_client.read_query() _ = self.connection.flush() assert response.raw["game_id"] == "MINECRAFT" assert response.motd == Motd.parse("\x00other") # "\u2a00other" is actually what is expected, # but the query protocol for vanilla has a bug when it comes to unicode handling. # The status protocol correctly shows "⨀other". def test_session_id(self): def session_id(): return 0x01010101 self.connection.receive(bytearray.fromhex("090000000035373033353037373800")) self.query_client._generate_session_id = Mock() self.query_client._generate_session_id = session_id self.query_client.handshake() conn_bytes = self.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert conn_bytes[3:] == session_id().to_bytes(4, byteorder="big") assert self.query_client.challenge == 570350778 py-mine-mcstatus-341e7a4/tests/protocol/test_query_client_async.py000066400000000000000000000037651524603215100256110ustar00rootroot00000000000000from typing import final from mcstatus._protocol.query_client import AsyncQueryClient from tests.protocol.helpers import AsyncDatagramConnection, async_decorator @final class TestAsyncQueryClient: def setup_method(self): self.connection = AsyncDatagramConnection() self.query_client = AsyncQueryClient(self.connection) # pyright: ignore[reportArgumentType] def test_handshake(self): self.connection.receive(bytearray.fromhex("090000000035373033353037373800")) async_decorator(self.query_client.handshake)() conn_bytes = self.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert self.query_client.challenge == 570350778 def test_query(self): self.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) response = async_decorator(self.query_client.read_query)() conn_bytes = self.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD00") assert conn_bytes[7:] == bytearray.fromhex("0000000000000000") assert response.raw == { "hostname": "A Minecraft Server", "gametype": "SMP", "game_id": "MINECRAFT", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "25565", "hostip": "192.168.56.1", } assert response.players.list == ["Dinnerbone", "Djinnibone", "Steve"] py-mine-mcstatus-341e7a4/tests/protocol/test_timeout.py000066400000000000000000000064611524603215100233730ustar00rootroot00000000000000import asyncio import typing from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from unittest.mock import AsyncMock, Mock, patch import pytest from mcstatus._net.address import Address from mcstatus._protocol.io.connection import TCPAsyncSocketConnection if typing.TYPE_CHECKING: from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] class FakeAsyncStream(asyncio.StreamReader): @override async def read(self, *args: typing.Any, **kwargs: typing.Any) -> typing.NoReturn: await asyncio.sleep(2) raise NotImplementedError("tests are designed to timeout before reaching this line") async def fake_asyncio_asyncio_open_connection( # should be async without await # ruff: ignore[unused-async] hostname: str, port: int ): return FakeAsyncStream(), None class TestAsyncSocketConnection: async def test_tcp_socket_read(self): with patch("asyncio.open_connection", fake_asyncio_asyncio_open_connection): async with TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) as tcp_async_socket: with pytest.raises(AsyncioTimeoutError): _ = await tcp_async_socket.read(10) async def test_tcp_socket_read_partial_data_then_eof(self): """Raise when the stream ends after only partial payload delivery.""" tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) tcp_async_socket.reader = Mock(read=AsyncMock(side_effect=[b"a", b""])) with pytest.raises( OSError, match=( r"^Server stopped responding \(got 1 bytes, but expected 2 bytes\). " r"Partial obtained data: bytearray\(b'a'\)$" ), ): _ = await tcp_async_socket.read(2) async def test_tcp_socket_read_eof_without_any_data(self): """Raise when the stream immediately ends without returning any data.""" tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) tcp_async_socket.reader = Mock(read=AsyncMock(return_value=b"")) with pytest.raises(OSError, match=r"^Server did not respond with any information!$"): _ = await tcp_async_socket.read(2) async def test_tcp_socket_write_awaits_drain(self): """Ensure writes await ``drain`` so buffered data is flushed.""" writer = Mock() writer.write = Mock() writer.drain = AsyncMock() tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) tcp_async_socket.writer = writer await tcp_async_socket.write(b"hello") writer.write.assert_called_once_with(b"hello") writer.drain.assert_awaited_once_with() async def test_tcp_socket_close_waits_for_writer(self): """Ensure close awaits ``wait_closed`` on the stream writer.""" writer = Mock() writer.close = Mock() writer.wait_closed = AsyncMock() tcp_async_socket = TCPAsyncSocketConnection(Address("dummy_address", 1234), timeout=0.01) tcp_async_socket.writer = typing.cast("asyncio.StreamWriter", writer) await tcp_async_socket.close() writer.close.assert_called_once_with() writer.wait_closed.assert_awaited_once_with() py-mine-mcstatus-341e7a4/tests/responses/000077500000000000000000000000001524603215100204455ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/responses/__init__.py000066400000000000000000000114151524603215100225600ustar00rootroot00000000000000from __future__ import annotations import abc from typing import Any, ClassVar, TYPE_CHECKING, TypeVar, cast import pytest if TYPE_CHECKING: from mcstatus.responses import BaseStatusResponse __all__ = ["BaseResponseTest"] _T = TypeVar("_T", bound="type[BaseResponseTest]") class BaseResponseTest(abc.ABC): EXPECTED_VALUES: ClassVar[list[tuple[str, Any]] | None] = None EXPECTED_TYPES: ClassVar[list[tuple[str, type]] | None] = None ATTRIBUTES_IN: ClassVar[list[str] | None] = None # if we don't specify item in raw answer, target field will be None # a first element is a list with fields to remove, and attribute that # must be None. a dict is a raw answer to pass into `build` method OPTIONAL_FIELDS: ClassVar[tuple[list[tuple[str, str]], dict[str, Any]] | None] = None def _validate(self) -> None: """Perform checks to validate the class.""" if self.EXPECTED_TYPES is not None and self.EXPECTED_VALUES is not None: expected_values_keys = list(dict(self.EXPECTED_VALUES).keys()) for key in dict(self.EXPECTED_TYPES): if key in expected_values_keys: raise ValueError("You can't test the type of attribute, if already testing its value.") if self.ATTRIBUTES_IN is not None and (self.EXPECTED_VALUES is not None or self.EXPECTED_TYPES is not None): if self.EXPECTED_VALUES and self.EXPECTED_TYPES: to_dict = self.EXPECTED_VALUES.copy() to_dict.extend(self.EXPECTED_TYPES) already_checked_attributes = dict(to_dict).keys() else: to_dict = cast("list[tuple[str, type]]", self.EXPECTED_VALUES or self.EXPECTED_TYPES) already_checked_attributes = dict(to_dict).keys() for attribute_name in self.ATTRIBUTES_IN: if attribute_name in already_checked_attributes: raise ValueError("You can't test the type availability, if already testing its value/type.") @abc.abstractmethod @pytest.fixture(scope="class") def build(self) -> BaseStatusResponse: ... # implementations for tests def test_values_of_attributes(self, build: BaseStatusResponse, field: str, value: Any) -> None: assert getattr(build, field) == value def test_types_of_attributes(self, build: BaseStatusResponse, field: str, type_: type) -> None: assert isinstance(getattr(build, field), type_) def test_attribute_in(self, build: BaseStatusResponse, field: str) -> None: assert hasattr(build, field) def test_optional_field_turns_into_none(self, build: BaseStatusResponse, to_remove: str, attribute_name: str) -> None: assert self.OPTIONAL_FIELDS is not None raw = self.OPTIONAL_FIELDS[1] del raw[to_remove] assert getattr(type(build).build(raw), attribute_name) is None def _dependency_table(self) -> dict[str, bool]: # a key in the dict must be a name of a test implementation. # and a value of the dict is a bool. if it's false - we # "delete" a test from the class. return { "test_values_of_attributes": self.EXPECTED_VALUES is not None, "test_types_of_attributes": self.EXPECTED_TYPES is not None, "test_attribute_in": self.ATTRIBUTES_IN is not None, "test_optional_field_turns_into_none": self.OPTIONAL_FIELDS is not None, } def _marks_table(self) -> dict[str, tuple[str, tuple[Any, ...]]]: # hooks in conftest.py parses this table # a key in the dict must be a name of a test implementation. # and a value of the dict is a tuple, where first element is # a name of mark to apply to the test, and second element is # positional arguments, which passed to the mark return { "test_values_of_attributes": ("parametrize", ("field,value", self.EXPECTED_VALUES)), "test_types_of_attributes": ("parametrize", ("field,type_", self.EXPECTED_TYPES)), "test_attribute_in": ("parametrize", ("field", self.ATTRIBUTES_IN)), "test_optional_field_turns_into_none": ( "parametrize", ("to_remove,attribute_name", self.OPTIONAL_FIELDS[0] if self.OPTIONAL_FIELDS is not None else ()), ), } @staticmethod def construct(class_: _T) -> _T: instance: BaseResponseTest = class_() # pyright: ignore[reportAssignmentType] instance._validate() for implementation_name, meet_dependencies in instance._dependency_table().items(): if not meet_dependencies: # delattr works only with initialized classes, # hopefully overwriting with None doesn't have this limitation setattr(class_, implementation_name, None) return class_ py-mine-mcstatus-341e7a4/tests/responses/conftest.py000066400000000000000000000022501524603215100226430ustar00rootroot00000000000000from __future__ import annotations import typing import pytest from tests.responses import BaseResponseTest if typing.TYPE_CHECKING: from _pytest.python import Function, Metafunc def pytest_generate_tests(metafunc: Metafunc) -> None: cls_ = typing.cast("type[typing.Any] | None", metafunc.cls) if cls_ is not None and issubclass(cls_, BaseResponseTest): instance = cls_() if metafunc.definition.name not in instance._marks_table(): return marker_name, args = instance._marks_table()[metafunc.definition.name] if marker_name != "parametrize": return # other markers will be handled in `pytest_collection_modifyitems` metafunc.parametrize(*args) def pytest_collection_modifyitems(items: list[Function]) -> None: for item in items: if isinstance(item.instance, BaseResponseTest): if item.obj.__name__ not in item.instance._marks_table(): continue marker_name, args = item.instance._marks_table()[item.obj.__name__] if marker_name == "parametrize": continue item.add_marker(getattr(pytest.mark, marker_name)(*args)) py-mine-mcstatus-341e7a4/tests/responses/test_base.py000066400000000000000000000004531524603215100227720ustar00rootroot00000000000000import pytest from mcstatus.responses import BaseStatusResponse class TestMCStatusResponse: def test_raises_not_implemented_error_on_build(self): with pytest.raises(NotImplementedError): _ = BaseStatusResponse.build({"foo": "bar"}) # pyright: ignore[reportAbstractUsage] py-mine-mcstatus-341e7a4/tests/responses/test_bedrock.py000066400000000000000000000064251524603215100234760ustar00rootroot00000000000000import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import ( BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion, BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion, ) from tests.responses import BaseResponseTest @pytest.fixture(scope="module") def build(): return BedrockStatusResponse.build( [ "MCPE", "§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r", "422", "1.18.100500", "1", "69", "3767071975391053022", "map name here", "Default", "1", "19132", "-1", "3", ], 123.0, ) @t.final @BaseResponseTest.construct class TestBedrockStatusResponse(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("motd", Motd.parse("§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r", bedrock=True)), ("latency", 123.0), ("map_name", "map name here"), ("gamemode", "Default"), ] EXPECTED_TYPES: t.ClassVar = [ ("players", BedrockStatusPlayers), ("version", BedrockStatusVersion), ] @pytest.fixture(scope="class") @staticmethod def build(build: BedrockStatusResponse) -> BedrockStatusResponse: return build @pytest.mark.parametrize(("field", "pop_index"), [("map_name", 7), ("gamemode", 7), ("gamemode", 8)]) def test_optional_parameters_is_none(self, field: str, pop_index: int): parameters = [ "MCPE", "§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r", "422", "1.18.100500", "1", "69", "3767071975391053022", "map name here", "Default", ] _ = parameters.pop(pop_index) # remove all variables after `pop_index` if len(parameters) - 1 == pop_index: _ = parameters.pop(pop_index) build = BedrockStatusResponse.build(parameters, 123.0) assert getattr(build, field) is None def test_as_dict(self, build: BedrockStatusResponse): assert build.as_dict() == { "gamemode": "Default", "latency": 123.0, "map_name": "map name here", "motd": "§4G§6a§ey§2B§1o§9w§ds§4e§6r", "players": {"max": 69, "online": 1}, "version": {"brand": "MCPE", "name": "1.18.100500", "protocol": 422}, } def test_description_alias(self, build: BedrockStatusResponse): assert build.description == "§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r" @t.final @BaseResponseTest.construct class TestBedrockStatusPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("online", 1), ("max", 69)] @pytest.fixture(scope="class") @staticmethod def build(build: BaseStatusResponse) -> BaseStatusPlayers: return build.players @t.final @BaseResponseTest.construct class TestBedrockStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.18.100500"), ("protocol", 422), ("brand", "MCPE")] @pytest.fixture(scope="class") @staticmethod def build(build: BaseStatusResponse) -> BaseStatusVersion: return build.version py-mine-mcstatus-341e7a4/tests/responses/test_forge_data.py000066400000000000000000001205571524603215100241630ustar00rootroot00000000000000import typing as t import pytest from mcstatus.responses import JavaStatusResponse from mcstatus.responses._raw import RawForgeData, RawJavaResponse from mcstatus.responses.forge import ForgeData, ForgeDataChannel, ForgeDataMod from tests.responses import BaseResponseTest JAVA_RAW_RESPONSE: RawJavaResponse = { "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", } @t.final @BaseResponseTest.construct class TestForgeDataV1(BaseResponseTest): RAW: t.ClassVar[dict[str, t.Any]] = { "type": "FML", "modList": [ {"modid": "minecraft", "version": "1.12.2"}, {"modid": "mcp", "version": "9.42"}, {"modid": "FML", "version": "8.0.99.99"}, {"modid": "forge", "version": "14.23.5.2859"}, ], "channels": [ {"res": "fml:handshake", "version": "1.2.3.4", "required": True}, ], } EXPECTED_VALUES: t.ClassVar = [ ("fml_network_version", 1), ("channels", [ForgeDataChannel(name="fml:handshake", version="1.2.3.4", required=True)]), ( "mods", [ ForgeDataMod(name="minecraft", marker="1.12.2"), ForgeDataMod(name="mcp", marker="9.42"), ForgeDataMod(name="FML", marker="8.0.99.99"), ForgeDataMod(name="forge", marker="14.23.5.2859"), ], ), ("truncated", False), ] @pytest.fixture(scope="class") @classmethod def build(cls) -> ForgeData: return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict @t.final @BaseResponseTest.construct class TestForgeDataV2(BaseResponseTest): RAW: t.ClassVar[dict[str, t.Any]] = { "fmlNetworkVersion": 2, "channels": [ {"res": "fml:handshake", "version": "1.2.3.4", "required": True}, ], "mods": [ {"modId": "forge", "modmarker": "ANY"}, {"modId": "fusion", "modmarker": ""}, ], } EXPECTED_VALUES: t.ClassVar = [ ("fml_network_version", 2), ("channels", [ForgeDataChannel(name="fml:handshake", version="1.2.3.4", required=True)]), ("mods", [ForgeDataMod(name="forge", marker="ANY"), ForgeDataMod(name="fusion", marker="")]), ("truncated", False), ] @pytest.fixture(scope="class") @classmethod def build(cls) -> ForgeData: return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict @t.final @BaseResponseTest.construct class TestForgeDataV3(BaseResponseTest): RAW: t.ClassVar[dict[str, t.Any]] = { "channels": [], "mods": [], "truncated": False, "fmlNetworkVersion": 3, "d": bytes.fromhex( "5e0000e0a084e390a4e78d8be39996e2b98ce1a698ccbae2b8b1e681a4e492b8e2a191e29ba7e6b2aee5a" "999e3a8b9e789a5e0b088e384b5e0a69ae28280e6b2aee5a999e3a8b9e789a5e0b088e384b5e0a69ae581" "80e6b380e5b29be38ab3e48483e38a9ce580b1e2ad8be79ca6e6b9abe1b29be392bae69daee68886e482b" "8e2a081dcb0e2b68ee5b49ae1a281e384ae02" ).decode("utf8"), } EXPECTED_VALUES: t.ClassVar = [ ("fml_network_version", 3), ( "channels", [ ForgeDataChannel(name="minecraft:unregister", version="FML3", required=True), ForgeDataChannel(name="minecraft:register", version="FML3", required=True), ForgeDataChannel(name="forge:tier_sorting", version="1.0", required=False), ForgeDataChannel(name="forge:split", version="1.1", required=True), ], ), ( "mods", [ ForgeDataMod(name="minecraft", marker="1.20.1"), ForgeDataMod(name="forge", marker="ANY"), ], ), ("truncated", False), ] @pytest.fixture(scope="class") @classmethod def build(cls) -> ForgeData: return ForgeData.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict @t.final class TestForgeDataMod: def test_build_with_empty_input(self): with pytest.raises(KeyError, match=r"^'Mod version in Forge mod data must be provided\. Mod info: {}'$"): _ = ForgeDataMod.build({}) def test_build_without_mod_id(self): with pytest.raises( KeyError, match=r"^\"Mod ID in Forge mod data must be provided\. Mod info: {'modmarker': 'foo'}\.\"$" ): _ = ForgeDataMod.build({"modmarker": "foo"}) @t.final @BaseResponseTest.construct class TestForgeData(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("fml_network_version", 3), ( "channels", [ ForgeDataChannel( name="cyclopscore:channel_main", version="1.0.0", required=True, ), ForgeDataChannel( name="supermartijn642configlib:sync_configs", version="1", required=False, ), ForgeDataChannel( name="alexsmobs:main_channel", version="1", required=False, ), ForgeDataChannel( name="sophisticatedcore:channel", version="1", required=False, ), ForgeDataChannel( name="rftoolsbase:rftoolsbase", version="1.0", required=True, ), ForgeDataChannel( name="irongenerators:irongenerators", version="1", required=False, ), ForgeDataChannel( name="xaeroworldmap:main", version="1.0", required=True, ), ForgeDataChannel( name="cookingforblockheads:network", version="1.0", required=False, ), ForgeDataChannel(name="xnet:xnet", version="1.0", required=True), ForgeDataChannel( name="placebo:placebo", version="1.0.0", required=True, ), ForgeDataChannel( name="citadel:main_channel", version="1", required=False, ), ForgeDataChannel( name="sophisticatedbackpacks:channel", version="1", required=False, ), ForgeDataChannel( name="buildinggadgets:main", version="4", required=False, ), ForgeDataChannel( name="mekanismgenerators:mekanismgenerators", version="10.2.5", required=False, ), ForgeDataChannel( name="waila:networking", version="1.0.0", required=True, ), ForgeDataChannel( name="shetiphiancore:main_channel", version="1.0.0", required=False, ), ForgeDataChannel( name="dummmmmmy:dummychannel", version="1", required=False, ), ForgeDataChannel( name="supplementaries:network", version="1", required=False, ), ForgeDataChannel( name="refinedstorage:main_channel", version="1", required=False, ), ForgeDataChannel(name="corpse:default", version="1.0.0", required=True), ForgeDataChannel( name="ping:ping_channel", version="PING1", required=True, ), ForgeDataChannel( name="ironfurnaces:ironfurnaces_network", version="1.0", required=True, ), ForgeDataChannel(name="botania:main", version="0", required=False), ForgeDataChannel(name="curios:main", version="1", required=False), # codespell:ignore curios ForgeDataChannel(name="patchouli:main", version="1", required=False), ForgeDataChannel(name="camera:default", version="1.0.0", required=True), ForgeDataChannel( name="libnonymous:channel", version="1.0", required=True, ), ForgeDataChannel( name="elevatorid:main_channel", version="1", required=False, ), ForgeDataChannel(name="worldedit:cui", version="1", required=True), ForgeDataChannel(name="worldedit:internal", version="1", required=True), ForgeDataChannel(name="cfm:network", version="1", required=False), ForgeDataChannel( name="architectury:network", version="1", required=True, ), ForgeDataChannel(name="trashcans:main", version="1", required=False), ForgeDataChannel(name="jei:channel", version="1.0.0", required=True), ForgeDataChannel(name="ae2:main", version="1", required=True), ForgeDataChannel( name="mekanism:mekanism", version="10.2.5", required=False, ), ForgeDataChannel(name="bdlib:multiblock", version="2", required=False), ForgeDataChannel(name="bdlib:misc", version="1", required=False), ForgeDataChannel(name="create:main", version="1", required=False), ForgeDataChannel( name="waystones:network", version="1.0", required=False, ), ForgeDataChannel(name="comforts:main", version="1", required=False), ForgeDataChannel( name="naturescompass:naturescompass", version="1.0", required=True, ), ForgeDataChannel( name="storagenetwork:main_channel", version="1", required=False, ), ForgeDataChannel(name="cofh_core:general", version="1", required=True), ForgeDataChannel( name="mcjtylib:mcjtylib", version="1.0", required=True, ), ForgeDataChannel( name="mininggadgets:main_network_channel", version="2", required=False, ), ForgeDataChannel( name="crafttweaker:main", version="1.0.0", required=False, ), ForgeDataChannel(name="akashictome:main", version="1", required=False), ForgeDataChannel( name="forge:tier_sorting", version="1.0", required=False, ), ForgeDataChannel(name="forge:split", version="1.1", required=True), ForgeDataChannel( name="colossalchests:channel_main", version="1.0.0", required=True, ), ForgeDataChannel(name="selene:network", version="1", required=False), ForgeDataChannel( name="craftingtweaks:network", version="1.0", required=False, ), ForgeDataChannel( name="minecraft:unregister", version="FML3", required=True, ), ForgeDataChannel( name="minecraft:register", version="FML3", required=True, ), ForgeDataChannel(name="titanium:network", version="1.0", required=True), ForgeDataChannel( name="easy_villagers:default", version="1.0.0", required=True, ), ForgeDataChannel(name="pipez:default", version="1.0.0", required=True), ForgeDataChannel(name="mantle:network", version="1", required=False), ForgeDataChannel(name="quark:main", version="1", required=False), ForgeDataChannel( name="xaerominimap:main", version="1.0", required=True, ), ForgeDataChannel( name="fastbench:channel", version="4.6.0", required=True, ), ForgeDataChannel(name="polymorph:main", version="1", required=False), ForgeDataChannel( name="storagedrawers:main_channel", version="1", required=False, ), ForgeDataChannel( name="enercell:network", version="0.0.0", required=False, ), ForgeDataChannel(name="appleskin:sync", version="1", required=True), ForgeDataChannel( name="modularrouters:main_channel", version="2", required=False, ), ForgeDataChannel( name="the_vault:network", version="0.26.0", required=False, ), ForgeDataChannel( name="modernui:fluxnetworks", version="707", required=False, ), ], ), ( "mods", [ ForgeDataMod(name="rsrequestify", marker="2.2.0"), ForgeDataMod(name="cyclopscore", marker="1.15.1"), ForgeDataMod(name="auudio", marker="1.0.3"), ForgeDataMod(name="auxiliaryblocks", marker="1.18.2-0.0.14"), ForgeDataMod(name="supermartijn642configlib", marker="1.1.6"), ForgeDataMod(name="alexsmobs", marker="1.18.6"), ForgeDataMod(name="architects_palette", marker="1.1.2"), ForgeDataMod(name="cagerium", marker="1.18.2-1.1.0"), ForgeDataMod(name="mcwwindows", marker="2.0.3"), ForgeDataMod( name="sophisticatedcore", marker="1.18.2-0.5.32.179", ), ForgeDataMod(name="thermal", marker="1.6.3.28"), ForgeDataMod(name="rftoolsbase", marker="1.18-3.0.9"), ForgeDataMod(name="initialinventory", marker="6.0.8"), ForgeDataMod(name="irongenerators", marker="2.0.1"), ForgeDataMod(name="xaeroworldmap", marker="1.25.1"), ForgeDataMod(name="cookingforblockheads", marker="12.0.2"), ForgeDataMod( name="controlling", marker="", ), ForgeDataMod(name="xnet", marker="1.18-4.0.5"), ForgeDataMod(name="placebo", marker="6.4.1"), ForgeDataMod(name="citadel", marker="1.11.3"), ForgeDataMod(name="powah", marker="3.0.1-beta"), ForgeDataMod(name="bookshelf", marker="13.2.50"), ForgeDataMod(name="lootbeams", marker="1.18.1"), ForgeDataMod( name="sophisticatedbackpacks", marker="1.18.2-3.18.35.752", ), ForgeDataMod(name="twigs", marker="1.1.4-patch1+1.18.2"), ForgeDataMod( name="buildinggadgets", marker="3.13.0-build.5+mc1.18.2}", ), ForgeDataMod(name="darkutils", marker="10.0.5"), ForgeDataMod(name="mcwdoors", marker="1.0.6"), ForgeDataMod(name="waddles", marker="1.18.2-0.8.19"), ForgeDataMod(name="mekanismgenerators", marker="10.2.5"), ForgeDataMod(name="balm", marker="3.2.0+0"), ForgeDataMod(name="waila", marker=""), ForgeDataMod(name="jeresources", marker="0.14.1.171"), ForgeDataMod( name="cloth_config", marker="", ), ForgeDataMod(name="shetiphiancore", marker="3.10.10"), ForgeDataMod(name="dummmmmmy", marker="1.18-1.5.2"), ForgeDataMod(name="supplementaries", marker="1.18.2-1.5.13"), ForgeDataMod(name="refinedstorage", marker="1.10.2"), ForgeDataMod(name="konkrete", marker="1.3.3"), ForgeDataMod(name="easy_piglins", marker="1.18.2-1.0.0"), ForgeDataMod(name="corpse", marker="1.18.2-1.0.2"), ForgeDataMod(name="packmenu", marker=""), ForgeDataMod(name="mcwbridges", marker="2.0.3"), ForgeDataMod(name="torchmaster", marker="18.1.0"), ForgeDataMod(name="compressium", marker="1.4.2-build.9+mc1.18.2"), ForgeDataMod(name="ping", marker="1.18-1.8.0"), ForgeDataMod(name="ironfurnaces", marker="3.3.1"), ForgeDataMod(name="mcwtrpdoors", marker="1.0.6"), ForgeDataMod(name="mcwfences", marker="1.0.5"), ForgeDataMod(name="supermartijn642corelib", marker="1.0.19"), ForgeDataMod(name="simplylight", marker="1.18.2-1.4.2-build.31"), ForgeDataMod(name="botania", marker="1.18.2-434"), ForgeDataMod(name="highlighter", marker="ANY"), ForgeDataMod(name="spark", marker=""), ForgeDataMod(name="curios", marker="1.18.2-5.0.7.1"), # codespell:ignore curios ForgeDataMod(name="patchouli", marker="1.18.2-71.1"), ForgeDataMod(name="camera", marker="1.18.2-1.0.4"), ForgeDataMod(name="blockcarpentry", marker="1.18-0.3.0"), ForgeDataMod(name="thermal_foundation", marker="1.6.3.28"), ForgeDataMod(name="thermal_expansion", marker="1.6.3.13"), ForgeDataMod(name="libnonymous", marker="2.1.0"), ForgeDataMod(name="elevatorid", marker="1.18.2-1.8.4"), ForgeDataMod(name="runelic", marker="11.0.1"), ForgeDataMod( name="worldedit", marker="", ), ForgeDataMod(name="cfm", marker="7.0.0-pre29"), ForgeDataMod(name="architectury", marker="4.9.84"), ForgeDataMod(name="weirdinggadget", marker="2.2.11"), ForgeDataMod(name="mcwfurnitures", marker="3.0.0"), ForgeDataMod(name="trashcans", marker="1.0.15"), ForgeDataMod(name="mcwlights", marker="1.0.3"), ForgeDataMod(name="cucumber", marker="5.1.2"), ForgeDataMod(name="snad", marker="1.18.2-1.22.04.15a"), ForgeDataMod(name="jei", marker="9.7.0.209"), ForgeDataMod(name="ae2", marker="11.1.4"), ForgeDataMod(name="mekanism", marker="10.2.5"), ForgeDataMod(name="bdlib", marker="1.19.3.7"), ForgeDataMod(name="create", marker="0.5.0.d"), ForgeDataMod(name="waystones", marker="10.1.0"), ForgeDataMod(name="clumps", marker="8.0.0+10"), ForgeDataMod(name="shutupexperimentalsettings", marker="1.0.5"), ForgeDataMod(name="comforts", marker="1.18.2-5.0.0.4"), ForgeDataMod(name="naturescompass", marker="1.18.2-1.9.7-forge"), ForgeDataMod(name="storagenetwork", marker="1.18.2-1.6.1"), ForgeDataMod(name="framedcompactdrawers", marker="1.18-4.1.0"), ForgeDataMod(name="decorative_blocks", marker="2.1.0"), ForgeDataMod(name="botanypots", marker="8.0.12"), ForgeDataMod(name="ftbbackups2", marker="1.0.17"), ForgeDataMod(name="cofh_core", marker="1.6.4.21"), ForgeDataMod(name="mcjtylib", marker="1.18-6.0.15"), ForgeDataMod(name="ispawner", marker="1.0"), ForgeDataMod(name="everycomp", marker="1.18.2-1.5.7"), ForgeDataMod(name="jeitweaker", marker="3.0.0.8"), ForgeDataMod(name="terralith", marker="0.0NONE"), ForgeDataMod(name="mininggadgets", marker="1.11.0"), ForgeDataMod(name="crafttweaker", marker="9.1.197"), ForgeDataMod(name="akashictome", marker="1.5-20"), ForgeDataMod(name="forge", marker="ANY"), ForgeDataMod(name="colossalchests", marker="1.8.3"), ForgeDataMod(name="selene", marker="1.18.2-1.17.9"), ForgeDataMod(name="drippyloadingscreen", marker="1.6.4"), ForgeDataMod( name="craftingtweaks", marker="", ), ForgeDataMod(name="minecraft", marker="1.18.2"), ForgeDataMod(name="terrablender", marker="1.18.2-1.1.0.102"), ForgeDataMod( name="sophisticatedbackpacksvh", marker="1.18.2-1.0.4.12", ), ForgeDataMod(name="mousetweaks", marker="ANY"), ForgeDataMod(name="titanium", marker="3.5.6"), ForgeDataMod(name="jade", marker=""), ForgeDataMod(name="createtweaker", marker="2.0.0.17"), ForgeDataMod(name="easy_villagers", marker="1.18.2-1.0.10"), ForgeDataMod(name="pipez", marker="1.18.2-1.1.5"), ForgeDataMod(name="iceberg", marker="ANY"), ForgeDataMod(name="flywheel", marker=""), ForgeDataMod(name="mantle", marker="1.9.27"), ForgeDataMod(name="ecologics", marker="1.7.3"), ForgeDataMod(name="quark", marker="3.2-358"), ForgeDataMod(name="xaerominimap", marker="22.11.1"), ForgeDataMod(name="pigpen", marker="8.0.1"), ForgeDataMod(name="fastbench", marker="6.0.2"), ForgeDataMod(name="polymorph", marker="1.18.2-0.44"), ForgeDataMod(name="autoreglib", marker="1.7-53"), ForgeDataMod(name="storagedrawers", marker="10.2.1"), ForgeDataMod(name="fluxnetworks", marker="7.0.7.8"), ForgeDataMod(name="neoncraft2", marker="2.2"), ForgeDataMod(name="enercell", marker="0.0NONE"), ForgeDataMod(name="appleskin", marker="2.4.0+mc1.18"), ForgeDataMod( name="ferritecore", marker="", ), ForgeDataMod(name="modularrouters", marker="9.1.1-93"), ForgeDataMod(name="refinedstorageaddons", marker="0.8.2"), ForgeDataMod(name="openloader", marker="12.0.1"), ForgeDataMod(name="the_vault", marker="1.18.2-2.0.10.869"), ], ), ("truncated", False), ] @pytest.fixture(scope="class") @staticmethod def build() -> ForgeData: value = ForgeData.build( RawForgeData( { "channels": [], "d": ( bytes.fromhex( "e0ba8b0000c484e4a0b0e18e9be19997e2baaee1b399e392bae7a5a6e6908ae4a2b8c5b1e380a3e2b1a1e1a39ee39eb6e" "78db0e5bb86e19789e0a0b3e18ba3e49aa6e0b18be38686e685a8e5b39ce38695e6abbbe19896e2b78de0b181e1a097e3" "80ae02d098e2aeabe19987e2b7ade0b181e1a097e38caee1b880e59684e4af83e19b86e4b0ade1b99ce398b1e68dafe69" "b96e490b5e0a5b1e68e83e29985e0b08be1a097e384aed1a8e4b1a0ceabe29997e2b6aee1b298e392bae6b9aae6a1ace0" "b388e78dbbe199a6e0b3ade1a99bcab1e2b8b1e5b1a2e38398e4ae98e39ba7e6aface1af98e38cb7e69da9cba6c384e4a" "090e49890e0b2ade5b39ee39eb6e78da2e6888ce492b8e78781e48da2e2b6a1e1a998e2beb7e6a1a3e5b382e196b9e0ad" "a3cc90e48080e1a184e386b9e6a5a8e4aba8e5868de7ae9be19c85e2b68ce1b499e38abae38485e6899ce4a2b8e48081e" "198b0e2b3ace5b299e3aab4e0b1ade5b1a2e68384e185b1e18b93e29786e0ae8c18e6b48ae6bb86e2979de28db3e79bb6" "e2b9aee0b281e1a097e38caee28884e3b78ce48e83e39a96e2ba8ee5a39ae3a8b0e691a5e5bb86e19789e0a28be18ba3e" "49c86e4b28be1a096e394aee6999ce3a388e3a689e78e93e0b1a0e1a19ae39cb7e6b1a5e6888200e280b8e59a87e2b98c" "e1a19bd0b6e2b8b1e5b1ace3a38ce48691e380a3e4b981e5b499e39eb7e78dace48b84e1978de0a193e18ba3e29c86e0b" "38be1a097e3a4aee69096e58699e7adbbe39b86e2b18ee5b398c6b2e2b8b1c9a0e48080e78d88e49a96e2b4aee5ac98e3" "9cb4e695b6e6a39ce4a6bde2af8be68da0e49885e0b88bdc81e789a9e5b39ee1969de2adb3e19ca6e6ba8ce5b29bcab9e" "2b8b2e5b1a0e3a384e18d88e69bb7e2b3ade5ae99e3a4b2e791a1e6939ed78dc688e580a0e2bc81e1a598e39eb9e6bdb7" "e5a3a4e39691cc8be181a7e49786e0b58ce1a297e6b484e58b82e0b6b9e78688e18c8240e5a385e39eb7e6a5abe4bb9ce" "3b699e18e93e79b86e6b1ade5a89ae382b2e78da4e6888ce3a388e78681e78ca2e2b780e5b499e39ebbe6adb2e68886e4" "82b8e0a081e382b0e4b7ace1b49be39eb9e6b1ace5b392e0a69de480a0e59ba7e4ba8ce0b182e1a297e2b4b8e5b1a8e3a" "380e286a9e69e80e0b2ade4839de19c98c4b0e0b884e38780e1ac8be29996e2b7ace0b681e1a897e384aee6808ed6b1e2" "ac9be798a6e282ade0ae8ce19c98c4b0e0b884e2968ce0aea3e59986e4b68ce0b181e1a297e2b8b1e1a1a6d6b4e78d8be" "397b6e2b48ce1ae98e38ab7c5ac62e19080e7ae80e19db6e4b48ce0b382e1a097e384aee4919ae58695cc8be28290e6b7" "ace5ab9be390b9e6b1a5e0bb8ce4b384e185b1e58ba3d886c980e39eb6e791afe4ab84e39685e38e9be68c90d8a5e4ae8" "ec498e78c96e6839ee296a1e28e9be39a97e0b0ace1a59de384b2e68da1e68396e0b685e1ad9be184a7e29786e0b88ce1" "a497e38cade6899ce3a3a0e2a699e78ba3e49aa6e4878ce390b1e6b9a1e4ab9cd6b1c688e58080e6ba80e5a99de3a6b3e" "38493e6899ce582b8c5a9e49897e0b1aee4b19ae1a295e384aee5b1b0e0a388e181b8e19d96e0b68de1a999e38eb7e685" "a7e4bb88e58695e48e9be68cb1e698a5e0ae8ce19a98e795a2e5a392e3a691e5a6a9e39b92e498ace0b18be19c9ce7b4b" "2e5a888e29685e0adb3cd80e28080e5a482e3a4b0e795abe58ba8e4b6b1e0a0b3e68c83e49885e0b58bd080e68dade4a3" "aee3b6bde1ae93e18197d786e0ae8c1be79c87e4a382e38691e1acabe18397e29786e0b88ce1a497e380ade7819ce492b" "8e18789e584a0e6b2ade1a19ae392b7e6b5b3e4ab8ee196b9e0ae93e79d86e6b98de4869ce1a098e388aee6a99ce39188" "e5acabe69896e6b4ade5ad9ce38ab3e695aee48ba4e3b791e1ae93e181a7e49886e0b28be1aa9700e49088e38685e3ada" "be68cb0e49985e4b08be1a095d483e48baee386a5e58c8be59ba0e6ba8ce1af9de396b9e6b9a9e0ab8ee3a384e78681e1" "8c82e68080e5aa82e3a4b2e78da5e6ab9ee0b789e1acabc2a7e29786e0b48ce1a297e384aee689aee38084e68c98e49bb" "6e6b48ee5a397e39cb7e6a5a6d38ee4b0b8e2ad83e19d86e0b88de5a99ae39cb0e6bda3e4aba4e4b09de0a5b1e68c83d8" "a5e48c8ce382b6e6b9a9e49abed6a1e78db3e49996e282ade0ae8ce19c9830e18884e59690e6adabe59b96e2b6ade1b99" "be1a285e384aee5a9b0e3a384e786a9e48ca2e2b281e5ad9de3b2b6e6a1a3e5b382e196b9e0ada3cc90e68180e5b383e3" "a0bae6b1b0e5ab8ae3a695e0aea3e19ca6e6b2ade48d9ce19c98e3a0b1e6919ce492b4e2a5b1e18ba3e699a6e5ae81e3a" "8b2e6bdb7e59ba4e49085e18081e283a0e4b2aee1a999e38ab7e78da4e5bba8d789e2acbbe181a6e29786e0b08ce1a497" "e6b48ce58b82e7b6b9e48c9ae69896e2b78de5ac99e1a28000e59890e3a6bde18d9be49997e2b2aee0b181e1a697e38ca" "ee1a080d694e4ae9bd7b7e6b4aee5ac99e39cb4e0b1b3e5b1a2e68384e185b1e18b93d786e0ae8cc498e68c86e6939ee4" "b781e68cabe68c90d8a5e0ae8ee19a99e2b8b1e5b1a0e1b388e2aca0e199a6e0baace5b49be1a282e380aee6819cd0844" "0e39897e2b5ace1a59be3aab7e0a880e49b9ae0a79de4ae93e79986e6b2acc59ce19c99e2b8b066e580ace18dbbd8b7e2" "b6ade1b398e38abad9b2e781a2e492b8c5b1e38083e6b1a1e1ad9be3a4b8e78da5e58ba6e39795e0a2b3e48ba3e49786e" "0ad8ce3aab1e6b1a9e5b388e2b3a4e1ada9e68c96d8a5e0ae8ec499e78084e5b392e2a69de78688cc92e296a7e0ae8ce1" "9c9ce0b0b0e58ba0e1b6b9e1abbbe19a86e4b78ce1a59bcab6e4a590e0ba9cd385e68090e29a90e4b7aee5a69be3a4bae" "685aee4ab86e1978de78698e68cb2d8a5e1a985e39eb9e699aee693aad6b9e2ac9be79cb6e2b78be5b499e39ebbe6adb2" "e68886e482b809e582b0e6b1ade1b49de3a0b9e6bda4e6939ee1978de78688e68c82db85e48980e386b6e699b7e5b38ae" "1968de2ae9be68c90e49885e0b58be0ac80e795b3e4aba0e39789e18c8be19d87e4b58de0b69be1a49ae6bda3e4aba4e2" "96b1e38c93e68c90e49885e4b18b1ce78c8be5ab92e38781e68f8be79a96e0b48ce4959de19c98e3a0b1e6919ce492b4e" "285b1e28ba3e496a6e5b598e398b4e2b9a4e689a6e1b088e7ac90e19d86e2b78ce1a19ae1a285e384aee5b1b0e39388e1" "a6a1e48d83e2b6a0e1a998c2b730e19880e296a0e48cbbe19b86e0b3ade5b49ae3a4b2e48483e38a9ce19085ce98e2989" "7e4b5aee48680e3aab1e6a5b2e69b9ee490b9e0a5b1e68e83e29985e0b58be1a097e39caee6899ce39090e4ac8be19ba6" "d8a0e48280e3a084e791a1e58386e596bde4ada3e182b6e29786e0b88ce1a497e39cade5b1a2e18384e0ada8e69a96e28" "0ad0ccc81e685a3e4ab9ad789e0a1a3e18ba3e49c86e4b28be1a296e380aee6a19ce1809ce38cabe59896e0b68ee4859d" "e19c98e2b8b0c9a0e3a080e68c90e39bb6e6b5ace1a198e3a0b9e6b9a5e693a8e2a7a5e78688cc92d6a7e4ae8ce19c993" "0e6a0a4e196a1e6ae93e49896e4afade5af99e39cbae685a4e58ba8e3a6bde0a183e68ba3e69786e0ae8ce1b099e18480" "e583a8e4a695e0adabe79b86e0b2abe5b09ee39cb0e6a5b3e5b39ee490a1e385b1e38ba3e29786e0b38cd681e6a5ace5b" "384e3a6bde6af8be59bb6e2b9aee0b281e1a297e380aee4988ed6a1e78db3e49996e281ade0ae8cc298e0a882e5a38ae5" "a695e28c8be29bb7e0b4aee48c99e19c98e3a0b1e6919ce492b4e485b1e48ba3e28686e5a19be39cb4e68d9fe48b90e3a" "6b9e68cabe1809606c780e3aab9e695aee58b98e1a68de0a688cba3e29786e4858ce3ae84e789afe4a398e18695e28d8b" "e380b7e2baace4819ac298e6a488e6a39ce4a695e0adb3e19b86e298a0e48280e38681e6b5a6e6b896e482b8c5b1cb93e" "2b98ee4b299ce9ce695aee6bba8e4a6bde0ad9bcc9040e1a183e386b9e6a5a8e4aba8e5868de18eabe69e97e49a80e0b9" "8be1b097dcb4e4ab9ce5b791e18dbbe19ab7e298a000e3ae87e6a5a5e4a3a4e3a6a5e3acbbe49896e2b3ace1b499e1a48" "3e388aee6899cc384e6a1a8e798b6e2b38ee1b29de392b7e795b4e4aba4e1978de78698e68c82e49885c980e3a4bae78d" "a1e49b90e3a685e38e9be68c90e49885e4b18bc89ae685ade5b392e4908501e58290e6b1ade5ac9de38eb4e791a8e0aba" "6e3a384e78681ccb2e68480e5b598e3aab1e689ade6938ae59095e0a5b1e28ba306e1b381e382b7e189a4e5b1a2e68384" "e185b1e18b93e49786e0b28ce1a097e2b8b4e6a9a2e0a684e58098e19996e284ade4ae8ee19c9be2b8b0e681a4e1b3a4e" "48c98e69896e2b78de5ac99e1a282e380aee6819ce0a084e0a098e29996e28386e0b18ce1a297e390aee5a888e29685e0" "adb3e18c9040e5ad82e396b2e6b9a1e69b92e1a6b5c688e28ba3e29786e4888de38ab6e685abe58b9ce3978de0a0b3e68" "c83e49985e0b58bc880e68885e5a388e0a6a5e0a183e18ba3e49ca6e0b38be1ae97e6b48ae5a3aae29791e68c93e39bb6" "e2b5ace0b280c880e6a5ade49ba6e49085e18081e381a0e2b98ce1a199e38abae38087e6a99ce482b8e285b1e58186e2b" "0ade5ae9ae1a280c880e6b892e69685e28e9be69bb7e6b2ade4869ce1a098e384aee6819ce3a09ce28cabe79db7e6b98d" "e4839ae19c9830e0b080e3868ce6aeabe39c86c48ee0ae8ee19c98e2acb0e681a2e6a080e48e98e49d96e0baaee1a59ce" "3a0bce789a5e5ab92e3a695e0aea3e39b86e0b2aee5b49de39cb4e78da7e6888ae482b8e2a5b123e6b1a1e1ad9be39eb3" "e791b2e1b3a6e3a384e48689e28ba3e296a6e0ae8de19c98e2b8b0e0a1a8d6b4e78d8be18096e48086ce80e382b7e795b" "4e4aba4e0b78de6adbbe19c86e6b9ace4929ce19c98e3a0b1e6919ce492b4e4a5b1e78ba3e496a6e1af99e38eb9e0b9a5" "e48b9ce59791e2ae93e39cb6e2b7ace5b09be3a6b0cdb3e5b1a2d380e78090e49cb0e4b7aee5a19ce38ab3e695aee6bba" "8e4a6bde68d9be68c90d8a5e0ae8ee19a99e2b8b1e5b1ace38384e0ada8e69a96e6afade5a898e39cb0e695aecb98c384" "e28080e299a1e2b0aee1a59be386b2e6b5afe48ba0e5868de18ca3e79897e4b2aee1b39ce1a285e384aee5a9b0e3a390e" "78689cc82c8a0e5a599e39eb1e685b2e58ba8e19799e18bbbe79b86e6b1ade5b39ae1a482e384aee6819ce2a080e7ac90" "e19d86e2b78ce5b09ee3a8b7d9b3e5b1b0e3a380e18689e38083e0b381e1a29de382b1e6ada3e683aae4a78de0a0b1cba" "3e29786e0b78cd281e6bda3e5838ce0b5bde18dbbd997e498a1e0b68be1a897e388aee0b9a2e1969ce2adb3e19ca6e2b6" "8ce4b180c480e6b488e59386e69791e4ada3e398a6e498a1e0b18be19a9ce2b8b6e5b1a0e59384e6a181e298b6e2ba8de" "5ac9ee384b4e38483e6819c04e4a180dcb6e6b0aee5ae9de3a4b2e38483e6819ce29080e38ca8e29997e6bcaee5af98e3" "a0b6e3848ce6899ce3a3a0e6a691e68c92e49aa5e0b78bd480e695aae6a392e1979de5ac8be29996e683aee0ae8ce19c9" "8e2b8b070e580a4e18cabe19ca7e2b68ce1b49aceb4e2b8b0e1b1a0e3a4bde188aae58390e4b4ade1a99be38eb7e685a7" "e4bb88e58695e38e9be68c90e298a5e0ae8ce0a898e685ade5b392e3a5bde28cabe79db7e6b98de59f9ae390b1e6b9a1e" "4ab9cd6b1c690e480a0e4b1a1e1a19ce3a8b3e79db4e48b8ae196ade3ae93e68e90e498a5e4b18be1ae9ce6b484e58b82" "e196b9e78688e68c82d885e48280e38285e685abe583a6e0b6a5e7aea3e59b96e2838ce4ae8ce19a9ae380b2e5a888e29" "685e0adb3cc90e28280e5a681e3a4b7e695a7c886e694b9e281a2e59a97e6b98ce5b397e3a4b7e6a5b4e4bb9ce4908dc5" "b1e58083e0b9a0e5ac9ce3a8b4e38483e6899ce0a084e1a1b0e49bb6e6b7ade5b39ce398b0e6a1a3e69b8ae4b791e0a0a" "bcba3e69787e48c8ce390b1e6b9a1e4ab9ce7b6b1e0adaae69a96e282ade0ae8ce19c98c4b0e0b084e1978ce2ada3e59b" "a6e286ace4ae8ce1b098e388aee6899ae492b8e786b9e78e92e2b780e5b499e39ebbe6adb2e6888200e28298e19ca6e0b" "88de1b99ce39eb6e691a1e5b392e4b69de18c9be59997e2b78ce0b181e1ac97e390aee1b086e4a68ce38c8be19d86e6b7" "8de5b499e38abbe6ada1e0bba6e196b8e3aea3e29bb7e6b5aee0b180e1a097d080e5a892e3a6a5e1acabe19ca6e0b38ce" "4869de19c98e3a0b1e6919ce590a8e18db3e79997e6b4ace5b49ce3a4b2e49884e1a29ad38de18180e79997e6b4ace5b4" "9ce3a4b2e49884e1a29ad38de68080e59d80e4b98ce1a19ce398b1e6b9a5e4ab88e48789e78688cc92e49787e4ad8ce19" "c98e2b8b1e5b1a0e48384c691e38680e0b7aee5a89ce3a6b4e6a5b4e48b86e19791e18ca3e39896e0b5ace5a19ce396b1" "e799b3e1bb90e3a384e48689e28ba3e296a6e0ae8ce19c98e2b8b4e691a2e2b080e7ada8e39d96e0b2aee5b79de382b2e" "78dabc886e694b9e48092e19d80e2ba8de5ae98e3aab4d5ade5b1a6e3a394e3a6b1e59ba0e6ba8ce1af9de396b9e38483" "e6819cd084e580a0e49896e0b2ace48d80e3a4b1e685a5e4aba8e5b791e0acabe59ab6e0b98ce0b282e1a097e380aee68" "99ce0a39ce2a1b0e39896e6bcaee5b697e398b4e685ace4ab8ee4b789e0a1abe18ba3e49c86e4b28be1a296e380aee689" "9ce1b380e2aca0e199a6e0baace5b49be1a282e380aee6819ce0a08428da97e4b2aee48c9ee19c98e3a0b1e6919ce492b" "4e0a5b1e58ba3c3a6e1a599e382b3e6b1b5e0aba8e3a384e78681e18c82e68080e5a981e38ab1e695a2e4bba4d08de4a9" "b215e0b381e5b99be390bbe695a5d398e39098e78c8be49d86e4b2ade0b181e1b297e388aee0b9aee196b8e3aea3e29bb" "7e2b5aee0b18000e69489e5bb86e3b6b1e4acbbe398b6e282aee4ae8ce19c9bc8b3e6888ad795e5ae93e381b6e49786e4" "ad8ce1aa99d0b8e48b9ae3a6a5e0a08be28083c680e5a19ee3a4b2e6b5afe5b392e396a5cc8be281b7e49986e4b18be19" "c98d0b1e48b9ae3a6a5e0a09bcba326c680e392b8e781a7e5b38ae68095c5b1e18ba3e28186e5a682e3a6b0e689b4e5b3" "8ae2868de380abcba3e49786e4878ce390b1e6b9a1e4ab9ce196b1e786a0e68da2e29885e48280e3a084e6b1afe5abb2e" "4a6bde48e83e182b6e29786e0b88ce1a497e380ade6a19ce18390e0ada8e69a96e280ad0cd480e795a1e5bba8e19789e6" "8cbbe29a96e2838ce4ae8ce19a9be38cb5e1b084e5878ce18dbbe79897e0b2ace5b299e3aeb0e789a5e0b3a6e48384e18" "5b1e18ba3e28686e5a19be39cb4e68d9fe48b90e3a6b9e68cabe1809606cc80e398b3e7a1b5e4ab9ce5b791e18dbbe39a" "b7e683aee0ae8de19c98e2b8b770e3a0a8e7acabe39ba6e2b98ce1a698e1a4bae38883e6919ce28088e78ca8e29996e2b" "1aee1ac99ceb6e2b8b0e1b1a0e3a4bde3a8aae59ba0e6ba8ce1af9de396b9e38085e6819ce482b8e18081e18290e0b88c" "e5ac9ce3a6b2e6a5abe1a39ce3a388e786a1e38c82e6b6a5e0b198e1a297d0b8e78ba6e0b6b9e0a08be18093e485a0e1a" "599e3a4b9e791a9e49b8ae4a6bde18cabe583a0e0b7ade1b599e382b6e789b2e6ab9ee19791e1ae93e18287e29787e4ae" "8ce19a98e38cb9e5a898e29685e7adb3d8b5e4b0ade5ae9be398b2e3888100e4a190e38cabe69a96e0b2ade1b399e39eb" "ae685b2e4ab8ee18685e7aca3e39ba6c2aee0ae8ce19c9c32e5b894e19781e68db3e19bb6e2b28ce1b299e1a283e2b8b2" "e5b1a0e0a384e28188e59a87e4aface5a19de398bae185b4e5b1a2e68384e185b1e28b93d786e4ae8ce1a098e3a0aee78" "9ace3a09ce28cabe79db7e6b98dc69ae19c98e398b2e6819cd080e6a2a8e49bb6e4b2ace5ae9ce392bae698bae6ab98e3" "a7a1e28cabe79db7e6b98de5b39ae1ae81e39cb000" ) ).decode("utf-8"), "fmlNetworkVersion": 3, "mods": [], "truncated": True, } ) ) assert value is not None return value def test_build_with_empty_input(self): with pytest.raises(KeyError, match=r"^'Neither `mods` or `modList` keys exist\.'$"): _ = ForgeData.build({}) @pytest.mark.parametrize("key", ["forgeData", "modinfo"]) def test_java_status_response_forge_data_is_none(key: str): # should not raise _ = JavaStatusResponse.build( JAVA_RAW_RESPONSE | {key: None}, # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict ) @pytest.mark.parametrize( ("key", "raw"), [ ("forgeData", TestForgeDataV2.RAW), ("modinfo", TestForgeDataV1.RAW), ], ) def test_java_status_response_forge_data(key: str, raw: bytes) -> None: assert JavaStatusResponse.build( JAVA_RAW_RESPONSE | {key: raw}, # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict ).forge_data == ForgeData.build(raw) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict py-mine-mcstatus-341e7a4/tests/responses/test_java.py000066400000000000000000000136471524603215100230120ustar00rootroot00000000000000import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion from tests.responses import BaseResponseTest @t.final @BaseResponseTest.construct class TestJavaStatusResponse(BaseResponseTest): RAW: t.ClassVar[dict[str, t.Any]] = { "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", } EXPECTED_VALUES: t.ClassVar = [ ("players", JavaStatusPlayers(0, 20, None)), ("version", JavaStatusVersion("1.8-pre1", 44)), ("motd", Motd.parse("A Minecraft Server", bedrock=False)), ("latency", 0), ("enforces_secure_chat", True), ("icon", "data:image/png;base64,foo"), ("raw", RAW), ("forge_data", None), ("is_modded", False), ] OPTIONAL_FIELDS: t.ClassVar = ( [("favicon", "icon"), ("enforcesSecureChat", "enforces_secure_chat")], { "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", }, ) @pytest.fixture(scope="class") @classmethod def build(cls) -> JavaStatusResponse: return JavaStatusResponse.build(cls.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict def test_as_dict(self, build: JavaStatusResponse): assert build.as_dict() == { "enforces_secure_chat": True, "forge_data": None, "icon": "data:image/png;base64,foo", "is_modded": False, "latency": 0, "motd": "A Minecraft Server", "players": {"max": 20, "online": 0, "sample": None}, "raw": { "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, }, "version": {"name": "1.8-pre1", "protocol": 44}, } def test_description_alias(self, build: JavaStatusResponse): assert build.description == "A Minecraft Server" def test_is_modded(self): build = JavaStatusResponse.build({**self.RAW, "isModded": True}) # pyright: ignore[reportArgumentType] assert build.is_modded is True def test_is_modded_with_forge_data(self): build = JavaStatusResponse.build( { # pyright: ignore[reportArgumentType] **self.RAW, "forgeData": { "fmlNetworkVersion": 2, "channels": [], "mods": [ {"modId": "forge", "modmarker": "ANY"}, ], }, } ) assert build.is_modded is True @t.final @BaseResponseTest.construct class TestJavaStatusPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("max", 20), ("online", 0), ( "sample", [ JavaStatusPlayer("foo", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"), JavaStatusPlayer("bar", "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"), JavaStatusPlayer("baz", "40e8d003-8872-412d-b09a-4431a5afcbd4"), ], ), ] OPTIONAL_FIELDS: t.ClassVar = ( [("sample", "sample")], { "max": 20, "online": 0, "sample": [ {"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"}, {"name": "bar", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"}, {"name": "baz", "id": "40e8d003-8872-412d-b09a-4431a5afcbd4"}, ], }, ) @pytest.fixture(scope="class") @staticmethod def build() -> JavaStatusPlayers: return JavaStatusPlayers.build( { "max": 20, "online": 0, "sample": [ {"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"}, {"name": "bar", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"}, {"name": "baz", "id": "40e8d003-8872-412d-b09a-4431a5afcbd4"}, ], } ) def test_empty_sample_turns_into_empty_list(self) -> None: assert JavaStatusPlayers.build({"max": 20, "online": 0, "sample": []}).sample == [] def test_java_status_players_sample_is_none(self) -> None: # should not raise assert ( JavaStatusPlayers.build( { "online": 1, "max": 123, "sample": None, } ).sample is None ) @t.final @BaseResponseTest.construct class TestJavaStatusPlayer(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "foo"), ("id", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89")] @pytest.fixture(scope="class") @staticmethod def build() -> JavaStatusPlayer: return JavaStatusPlayer.build({"name": "foo", "id": "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89"}) def test_id_field_the_same_as_uuid(self) -> None: unique = object() build = JavaStatusPlayer.build({"name": "foo", "id": unique}) # pyright: ignore[reportArgumentType] assert build.id is build.uuid assert build.uuid is unique @t.final @BaseResponseTest.construct class TestJavaStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.8-pre1"), ("protocol", 44)] @pytest.fixture(scope="class") @staticmethod def build() -> JavaStatusVersion: return JavaStatusVersion.build({"name": "1.8-pre1", "protocol": 44}) py-mine-mcstatus-341e7a4/tests/responses/test_legacy.py000066400000000000000000000034261524603215100233270ustar00rootroot00000000000000import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion from tests.responses import BaseResponseTest @pytest.fixture(scope="module") def build(): return LegacyStatusResponse.build( [ "47", "1.4.2", "A Minecraft Server", "0", "20", ], 123.0, ) @t.final @BaseResponseTest.construct class TestLegacyStatusResponse(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("motd", Motd.parse("A Minecraft Server")), ("latency", 123.0), ] EXPECTED_TYPES: t.ClassVar = [ ("players", LegacyStatusPlayers), ("version", LegacyStatusVersion), ] @pytest.fixture(scope="class") @staticmethod def build(build: LegacyStatusResponse) -> LegacyStatusResponse: return build def test_as_dict(self, build: LegacyStatusResponse): assert build.as_dict() == { "latency": 123.0, "motd": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.4.2", "protocol": 47}, } @t.final @BaseResponseTest.construct class TestLegacyStatusPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("online", 0), ("max", 20)] @pytest.fixture(scope="class") @staticmethod def build(build: LegacyStatusResponse) -> LegacyStatusPlayers: return build.players @t.final @BaseResponseTest.construct class TestLegacyStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.4.2"), ("protocol", 47)] @pytest.fixture(scope="class") @staticmethod def build(build: LegacyStatusResponse) -> LegacyStatusVersion: return build.version py-mine-mcstatus-341e7a4/tests/responses/test_query.py000066400000000000000000000077021524603215100232310ustar00rootroot00000000000000import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import QueryPlayers, QueryResponse, QuerySoftware from mcstatus.responses._raw import RawQueryResponse from tests.responses import BaseResponseTest @t.final @BaseResponseTest.construct class TestQueryResponse(BaseResponseTest): RAW: t.ClassVar[RawQueryResponse] = RawQueryResponse( hostname="A Minecraft Server", gametype="GAME TYPE", # pyright: ignore[reportArgumentType] # different from the hardcoded value game_id="GAME ID", # pyright: ignore[reportArgumentType] # different from the hardcoded value version="1.8", plugins="", map="world", numplayers="3", maxplayers="20", hostport="9999", hostip="192.168.56.1", ) RAW_PLAYERS: t.ClassVar = ["Dinnerbone", "Djinnibone", "Steve"] EXPECTED_VALUES: t.ClassVar = [ ("raw", RAW), ("motd", Motd.parse("A Minecraft Server")), ("map_name", "world"), ("players", QueryPlayers(online=3, max=20, list=["Dinnerbone", "Djinnibone", "Steve"])), ("software", QuerySoftware(version="1.8", brand="vanilla", plugins=[])), ("ip", "192.168.56.1"), ("port", 9999), ("game_type", "GAME TYPE"), ("game_id", "GAME ID"), ] @pytest.fixture(scope="class") @classmethod def build(cls): return QueryResponse.build(raw=cls.RAW, players_list=cls.RAW_PLAYERS) def test_as_dict(self, build: QueryResponse): assert build.as_dict() == { "game_id": "GAME ID", "game_type": "GAME TYPE", "ip": "192.168.56.1", "map_name": "world", "motd": "A Minecraft Server", "players": { "list": [ "Dinnerbone", "Djinnibone", "Steve", ], "max": 20, "online": 3, }, "port": 9999, "raw": { "game_id": "GAME ID", "gametype": "GAME TYPE", "hostip": "192.168.56.1", "hostname": "A Minecraft Server", "hostport": "9999", "map": "world", "maxplayers": "20", "numplayers": "3", "plugins": "", "version": "1.8", }, "software": { "brand": "vanilla", "plugins": [], "version": "1.8", }, } @t.final @BaseResponseTest.construct class TestQueryPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("online", 3), ("max", 20), ("list", ["Dinnerbone", "Djinnibone", "Steve"]), ] @pytest.fixture(scope="class") @staticmethod def build(): return QueryPlayers.build( raw={ "hostname": "A Minecraft Server", "gametype": "SMP", "game_id": "MINECRAFT", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "25565", "hostip": "192.168.56.1", }, players_list=["Dinnerbone", "Djinnibone", "Steve"], ) class TestQuerySoftware: def test_vanilla(self): software = QuerySoftware.build("1.8", "") assert software.brand == "vanilla" assert software.version == "1.8" assert software.plugins == [] def test_modded(self): software = QuerySoftware.build("1.8", "A modded server: Foo 1.0; Bar 2.0; Baz 3.0") assert software.brand == "A modded server" assert software.plugins == ["Foo 1.0", "Bar 2.0", "Baz 3.0"] def test_modded_no_plugins(self): software = QuerySoftware.build("1.8", "A modded server") assert software.brand == "A modded server" assert software.plugins == [] py-mine-mcstatus-341e7a4/tests/test_cli.py000066400000000000000000000332061524603215100206100ustar00rootroot00000000000000import contextlib import io import json import os import socket from collections.abc import Generator from typing import Any from unittest import mock from unittest.mock import patch import pytest from mcstatus import BedrockServer, JavaServer, LegacyServer from mcstatus.__main__ import PING_PACKET_FAIL_WARNING, QUERY_FAIL_WARNING, main as main_under_test from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse from mcstatus.responses._raw import RawJavaResponse JAVA_RAW_RESPONSE: RawJavaResponse = { "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", } QUERY_RAW_RESPONSE: list[Any] = [ { "hostname": "A Minecraft Server", "gametype": "GAME TYPE", "game_id": "GAME ID", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "9999", "hostip": "192.168.56.1", }, ["Dinnerbone", "Djinnibone", "Steve"], ] LEGACY_RAW_RESPONSE = [ "47", "1.4.2", "A Minecraft Server", "0", "20", ] BEDROCK_RAW_RESPONSE = [ "MCPE", "§r§4G§r§6a§r§ey§r§2B§r§1o§r§9w§r§ds§r§4e§r§6r", "422", "1.18.100500", "1", "69", "3767071975391053022", "map name here", "Default", "1", "19132", "-1", "3", ] # NOTE: if updating this, be sure to change other occurrences of this help text! # to update, use: `COLUMNS=100000 poetry run mcstatus --help` EXPECTED_HELP_OUTPUT = """ usage: mcstatus [-h] [--bedrock | --legacy] address {ping,status,query,json} ... mcstatus provides an easy way to query Minecraft servers for any information they can expose. It provides three modes of access: query, status, ping and json. positional arguments: address The address of the server. options: -h, --help show this help message and exit --bedrock Specifies that 'address' is a Bedrock server (default: Java). --legacy Specifies that 'address' is a pre-1.7 Java server (default: 1.7+). commands: Command to run, defaults to 'status'. {ping,status,query,json} ping Ping server for latency. status Prints server status. query Prints detailed server information. Must be enabled in servers' server.properties file. json Prints server status and query in json. """ # ruff: ignore[line-too-long] @contextlib.contextmanager def patch_stdout_stderr(): outpatch = patch("sys.stdout", new=io.StringIO()) errpatch = patch("sys.stderr", new=io.StringIO()) with outpatch as out, errpatch as err: yield out, err @pytest.fixture def mock_network_requests() -> Generator[None]: with ( patch("mcstatus.server.JavaServer.lookup", return_value=JavaServer("example.com", port=25565)), patch("mcstatus.server.JavaServer.ping", return_value=0), patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(JAVA_RAW_RESPONSE)), patch("mcstatus.server.JavaServer.query", return_value=QueryResponse.build(*QUERY_RAW_RESPONSE)), patch("mcstatus.server.LegacyServer.lookup", return_value=LegacyServer("example.com", port=25565)), patch( "mcstatus.server.LegacyServer.status", return_value=LegacyStatusResponse.build(LEGACY_RAW_RESPONSE, latency=123) ), patch("mcstatus.server.BedrockServer.lookup", return_value=BedrockServer("example.com", port=25565)), patch( "mcstatus.server.BedrockServer.status", return_value=(BedrockStatusResponse.build(BEDROCK_RAW_RESPONSE, latency=123)), ), ): yield def normalise_help_output(s: str) -> str: """Normalises the output of `mcstatus --help`. A work around some discrepancies between Python versions while still retaining meaningful information for comparison. """ elided = "[...]:" s = s.strip() # drop lines which end in ":". these argparse section headings vary between python versions. # it is just a small style change, so it doesn't matter so much to do `sys.version_info` check return "\n".join(ln if not ln.endswith(":") else elided for ln in s.splitlines()) # NOTE: for premature exits in argparse, we must catch SystemExit. # for ordinary exits in the CLI code, we can simply inspect the return value. def test_no_args(): with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^2$") as exn: _ = main_under_test([]) assert out.getvalue() == "" assert "usage: " in err.getvalue() assert exn.value.code != 0 def test_help(): with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^0$") as exn: _ = main_under_test(["--help"]) assert "usage: " in out.getvalue() assert err.getvalue() == "" assert exn.value.code == 0 @mock.patch.dict(os.environ, {"COLUMNS": "100000"}) # prevent line-wrapping in --help output def test_help_matches_recorded_output(): with patch_stdout_stderr() as (out, err), pytest.raises(SystemExit, match=r"^0$"): _ = main_under_test(["--help"]) assert normalise_help_output(out.getvalue()) == normalise_help_output(EXPECTED_HELP_OUTPUT) assert err.getvalue() == "" def test_one_argument_is_status(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com"]) == 0 assert out.getvalue() == ( "version: Java 1.8-pre1 (protocol 44)\n" "motd: \x1b[0mA Minecraft Server\x1b[0m\n" "players: 0/20\n" "ping: 0.00 ms\n" ) # fmt: skip assert err.getvalue() == "" def test_status(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "status"]) == 0 assert out.getvalue() == ( "version: Java 1.8-pre1 (protocol 44)\n" "motd: \x1b[0mA Minecraft Server\x1b[0m\n" "players: 0/20\n" "ping: 0.00 ms\n" ) # fmt: skip assert err.getvalue() == "" def test_status_with_sample(mock_network_requests: None): raw_response = JAVA_RAW_RESPONSE.copy() raw_response["players"] = JAVA_RAW_RESPONSE["players"].copy() raw_response["players"]["sample"] = [ {"name": "foo", "id": "497dcba3-ecbf-4587-a2dd-5eb0665e6880"}, {"name": "bar", "id": "50e14f43-dd4e-412f-864d-78943ea28d91"}, {"name": "baz", "id": "7edb3b2e-869c-485b-af70-76a934e0fcfd"}, ] with ( patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(raw_response)), patch_stdout_stderr() as (out, err), ): assert main_under_test(["example.com", "status"]) == 0 assert out.getvalue() == ( "version: Java 1.8-pre1 (protocol 44)\n" "motd: \x1b[0mA Minecraft Server\x1b[0m\n" "players: 0/20\n" " foo (497dcba3-ecbf-4587-a2dd-5eb0665e6880)\n" " bar (50e14f43-dd4e-412f-864d-78943ea28d91)\n" " baz (7edb3b2e-869c-485b-af70-76a934e0fcfd)\n" "ping: 0.00 ms\n" ) assert err.getvalue() == "" def test_status_sample_empty_list(mock_network_requests: None): raw_response = JAVA_RAW_RESPONSE.copy() raw_response["players"] = JAVA_RAW_RESPONSE["players"].copy() raw_response["players"]["sample"] = [] with ( patch("mcstatus.server.JavaServer.status", return_value=JavaStatusResponse.build(raw_response)), patch_stdout_stderr() as (out, err), ): assert main_under_test(["example.com", "status"]) == 0 assert out.getvalue() == ( "version: Java 1.8-pre1 (protocol 44)\n" "motd: \x1b[0mA Minecraft Server\x1b[0m\n" "players: 0/20\n" "ping: 0.00 ms\n" ) # fmt: skip assert err.getvalue() == "" def test_status_bedrock(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--bedrock", "status"]) == 0 assert out.getvalue() == ( "version: Bedrock 1.18.100500 (protocol 422)\n" "motd: \x1b[0m\x1b[0m\x1b[0m\x1b[38;2;170;0;0mG\x1b[0m\x1b[0m\x1b[38;2;255;170;0ma\x1b[0m\x1b[0m\x1b[38;2;255;255;85m" "y\x1b[0m\x1b[0m\x1b[38;2;0;170;0mB\x1b[0m\x1b[0m\x1b[38;2;0;0;170mo\x1b[0m\x1b[0m\x1b[38;2;85;85;255mw\x1b[0m\x1b[0m" "\x1b[38;2;255;85;255ms\x1b[0m\x1b[0m\x1b[38;2;170;0;0me\x1b[0m\x1b[0m\x1b[38;2;255;170;0mr\x1b[0m\n" "players: 1/69\n" "ping: 123.00 ms\n" ) assert err.getvalue() == "" def test_status_legacy(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--legacy", "status"]) == 0 assert out.getvalue() == ( "version: Java (pre-1.7) 1.4.2 (protocol 47)\nmotd: \x1b[0mA Minecraft Server\x1b[0m\nplayers: 0/20\nping: 123.00 ms\n" ) assert err.getvalue() == "" def test_status_offline(mock_network_requests: None): with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.status", side_effect=TimeoutError): assert main_under_test(["example.com", "status"]) == 1 assert out.getvalue() == "" assert err.getvalue() == "Error: TimeoutError()\n" def test_query(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "query"]) == 0 assert out.getvalue() == ( "host: 192.168.56.1:9999\n" "software: Java 1.8 vanilla\n" "motd: \x1b[0mA Minecraft Server\x1b[0m\n" "plugins: []\n" "players: 3/20 ['Dinnerbone', 'Djinnibone', 'Steve']\n" ) assert err.getvalue() == "" def test_query_offline(mock_network_requests: None): with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.query", side_effect=socket.timeout): assert main_under_test(["example.com", "query"]) != 0 assert out.getvalue() == "" assert err.getvalue() == QUERY_FAIL_WARNING + "\n" def test_query_on_bedrock(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--bedrock", "query"]) != 0 assert out.getvalue() == "" assert err.getvalue() == "The 'query' protocol is only supported by Java servers.\n" def test_json(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "json"]) == 0 data = json.loads(out.getvalue()) assert data == { "online": True, "kind": "Java", "status": { "players": {"online": 0, "max": 20, "sample": None}, "version": {"name": "1.8-pre1", "protocol": 44}, "motd": "A Minecraft Server", "latency": 0, "raw": { "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44}, "description": "A Minecraft Server", "enforcesSecureChat": True, "favicon": "data:image/png;base64,foo", }, "enforces_secure_chat": True, "icon": "data:image/png;base64,foo", "forge_data": None, "is_modded": False, }, "query": { "ip": "192.168.56.1", "port": 9999, "map_name": "world", "motd": "A Minecraft Server", "game_id": "GAME ID", "game_type": "GAME TYPE", "players": { "list": [ "Dinnerbone", "Djinnibone", "Steve", ], "max": 20, "online": 3, }, "software": { "brand": "vanilla", "plugins": [], "version": "1.8", }, "raw": { "hostname": "A Minecraft Server", "gametype": "GAME TYPE", "game_id": "GAME ID", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "9999", "hostip": "192.168.56.1", }, }, } assert err.getvalue() == "" def test_ping(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "ping"]) == 0 assert float(out.getvalue()) == 0 # this floating comparison is safe # ruff: ignore[float-equality-comparison] assert err.getvalue() == "" def test_ping_bedrock(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--bedrock", "ping"]) == 0 assert float(out.getvalue()) == 123 # this floating comparison is safe # ruff: ignore[float-equality-comparison] assert err.getvalue() == "" def test_ping_legacy(mock_network_requests: None): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--legacy", "ping"]) == 0 assert float(out.getvalue()) == 123 # this floating comparison is safe # ruff: ignore[float-equality-comparison] assert err.getvalue() == "" def test_ping_server_doesnt_support(mock_network_requests: None): with patch_stdout_stderr() as (out, err), patch("mcstatus.server.JavaServer.ping", side_effect=TimeoutError("timeout")): assert main_under_test(["example.com", "ping"]) == 0 assert float(out.getvalue()) == 0 # this floating comparison is safe # ruff: ignore[float-equality-comparison] assert err.getvalue() == PING_PACKET_FAIL_WARNING.format(address="example.com:25565", ping_exc="timeout") + "\n" py-mine-mcstatus-341e7a4/tests/test_compat.py000066400000000000000000000131471524603215100213260ustar00rootroot00000000000000"""Tests for compatibility shims and build-time packaging behavior.""" import importlib import os import shutil import sys import tarfile import zipfile from collections.abc import Generator from contextlib import contextmanager from pathlib import Path from typing import Literal import pytest from hatchling.build import build_sdist, build_wheel from tests.helpers import patch_project_version @contextmanager def _chdir(path: Path) -> Generator[None]: """Temporarily change the working directory (Python 3.10 compatibility equivalent of ``contextlib.chdir``).""" original = Path.cwd() os.chdir(path) try: yield finally: os.chdir(original) def _extractall_compat(tar: tarfile.TarFile, destination: Path) -> None: """Extract a tar archive in a way that works across Python versions and platforms. This is a helper utility that avoids a deprecation warning from tarfile stdlib. Python 3.14 deprecates ``TarFile.extractall`` without a filter, but Python 3.10 on Windows does not accept the ``filter`` keyword. Use the secure filter when available, and fall back to the legacy call only when the runtime rejects the keyword argument. """ try: tar.extractall(destination, filter="data") except TypeError as exc: if "unexpected keyword argument 'filter'" not in str(exc): raise # This call is unsafe for malicious archives, due to path escapes (like files # with ../foo getting placed outside of our destination) but we know what we # built and this is only used within unit-tests, so it's not really important # to be strict about handling this. tar.extractall(destination) # ruff: ignore[tarfile-unsafe-members] @pytest.mark.parametrize("raises", [False, True]) @pytest.mark.parametrize( ("module", "msg_pattern"), [ ("mcstatus._compat.forge_data", r"use mcstatus\.responses\.forge instead"), ], ) def test_deprecated_import_path(raises: bool, module: str, msg_pattern: str): """Test that the compatibility shims emit deprecation warnings at import time. Note that this does NOT test the actual inclusion of the compatibility modules into the source tree at build time. This test intentionally only uses the _compat imports, as the shim files are only included on build time, which means testing those directly would fail. """ # importlib.import_module caches module, if it didn't raise _ = sys.modules.pop(module, None) context_manager = ( pytest.raises(DeprecationWarning, match=msg_pattern) if raises else pytest.deprecated_call(match=msg_pattern) ) with patch_project_version("100.0.0" if raises else "0.0.0"), context_manager: _ = importlib.import_module(module) @pytest.fixture(scope="session") def sdist_path(tmp_path_factory: pytest.TempPathFactory) -> Path: """Build an sdist once and return the path of the temporary directory where it exists.""" source_root = Path(__file__).resolve().parent.parent tmp_dir = tmp_path_factory.mktemp("build") tmp_path = Path(tmp_dir) build_root = tmp_path / "mcstatus" _ = shutil.copytree( source_root, build_root, ignore=shutil.ignore_patterns( ".git", ".venv", "__pycache__", ".pytest_cache", "dist", "build", "_build", ".ruff_cache", ), ) dist_dir = tmp_path / "dist" dist_dir.mkdir() # Build from a clean temp copy so we validate the sdist contents. with _chdir(build_root): sdist_name = build_sdist(str(dist_dir)) sdist_path = dist_dir / sdist_name return sdist_path @pytest.fixture(scope="session") def sdist_member_names(sdist_path: Path) -> set[str]: """Build an sdist once and return all archive member names.""" with tarfile.open(sdist_path, "r:gz") as tar: tar_names = set(tar.getnames()) return tar_names @pytest.fixture(scope="session") def wheel_member_names(sdist_path: Path, tmp_path_factory: pytest.TempPathFactory) -> set[str]: """Build a wheel once and return all archive member names.""" tmp_path = tmp_path_factory.mktemp("wheel-build") # Extract the sdist files first sdist_extract_root = tmp_path / "sdist" sdist_extract_root.mkdir() with tarfile.open(sdist_path, "r:gz") as tar: _extractall_compat(tar, sdist_extract_root) # Get the first (and only) subdir inside of the sdist extract directory. # This will contain the sdist files from this specific build (e.g. mcstatus-0.0.0). sdist_root = next(path for path in sdist_extract_root.iterdir() if path.is_dir()) wheel_build_dir = tmp_path / "dist" wheel_build_dir.mkdir() # Build the wheel from the sdist content to ensure compat shims persist. with _chdir(sdist_root): wheel_name = build_wheel(str(wheel_build_dir)) wheel_path = wheel_build_dir / wheel_name with zipfile.ZipFile(wheel_path) as wheel: wheel_names = set(wheel.namelist()) return wheel_names @pytest.mark.parametrize("member_names_from", ["sdist", "wheel"]) @pytest.mark.parametrize( "expected_path", [ "mcstatus/forge_data.py", ], ) def test_includes_compat_shims( sdist_member_names: set[str], wheel_member_names: set[str], member_names_from: Literal["sdist", "wheel"], expected_path: str, ) -> None: """Assert the built wheel and sdist both bundle compatibility shims into their legacy paths.""" member_names = sdist_member_names if member_names_from == "sdist" else wheel_member_names assert any(name.endswith(expected_path) for name in member_names) py-mine-mcstatus-341e7a4/tests/test_server.py000066400000000000000000000270141524603215100213470ustar00rootroot00000000000000from __future__ import annotations import asyncio from typing import TYPE_CHECKING, final from unittest.mock import call, patch import pytest import pytest_asyncio from mcstatus._net.address import Address from mcstatus.server import BedrockServer, JavaServer, LegacyServer from tests.protocol.helpers import AsyncBufferConnection, SyncBufferConnection, SyncDatagramConnection if TYPE_CHECKING: from collections.abc import Awaitable, Callable from typing_extensions import override else: override = lambda f: f # ruff: ignore[lambda-assignment] @final class MockProtocolFactory(asyncio.Protocol): transport: asyncio.Transport def __init__(self, data_expected_to_receive: bytes, data_to_respond_with: bytes): self.data_expected_to_receive = data_expected_to_receive self.data_to_respond_with = data_to_respond_with @override def connection_made(self, transport: asyncio.Transport): # pyright: ignore[reportIncompatibleMethodOverride] print("connection_made") self.transport = transport @override def connection_lost(self, exc): print("connection_lost") self.transport.close() @override def data_received(self, data): assert self.data_expected_to_receive in data self.transport.write(self.data_to_respond_with) @override def eof_received(self): print("eof_received") @override def pause_writing(self): print("pause_writing") @override def resume_writing(self): print("resume_writing") @pytest_asyncio.fixture async def create_mock_packet_server(): """Create a temporary asyncio packet servers used by tests.""" event_loop = asyncio.get_running_loop() servers: list[asyncio.Server] = [] async def create_server(port: int, data_expected_to_receive: bytes, data_to_respond_with: bytes) -> asyncio.Server: """Start a server that validates one request pattern and returns a fixed payload.""" server = await event_loop.create_server( lambda: MockProtocolFactory(data_expected_to_receive, data_to_respond_with), host="127.0.0.1", port=port, ) servers.append(server) return server yield create_server for server in servers: server.close() await server.wait_closed() @final class TestBedrockServer: def setup_method(self): self.server = BedrockServer("127.0.0.1") def test_default_port(self): assert self.server.address.port == 19132 def test_lookup_constructor(self): s = BedrockServer.lookup("example.org") assert s.address.host == "example.org" assert s.address.port == 19132 @final class TestAsyncJavaServer: async def test_async_ping( self, unused_tcp_port: int, create_mock_packet_server: Callable[..., Awaitable[asyncio.Server]], ): _ = await create_mock_packet_server( port=unused_tcp_port, data_expected_to_receive=bytearray.fromhex("09010000000001C54246"), data_to_respond_with=bytearray.fromhex("0F002F096C6F63616C686F737463DD0109010000000001C54246"), ) minecraft_server = JavaServer("127.0.0.1", port=unused_tcp_port) latency = await minecraft_server.async_ping(ping_token=29704774, version=47) assert latency >= 0 async def test_async_lookup_constructor(self): s = await JavaServer.async_lookup("example.org:3333") assert s.address.host == "example.org" assert s.address.port == 3333 def test_java_server_with_query_port(): with patch("mcstatus.server.JavaServer._retry_query") as patched_query_func: server = JavaServer("127.0.0.1", query_port=12345) _ = server.query() assert server.query_port == 12345 assert patched_query_func.call_args == call(Address("127.0.0.1", port=12345), tries=3) async def test_java_server_with_query_port_async(): with patch("mcstatus.server.JavaServer._retry_async_query") as patched_query_func: server = JavaServer("127.0.0.1", query_port=12345) _ = await server.async_query() assert server.query_port == 12345 assert patched_query_func.call_args == call(Address("127.0.0.1", port=12345), tries=3) @final class TestJavaServer: def setup_method(self): self.socket = SyncBufferConnection() self.server = JavaServer("127.0.0.1") def test_default_port(self): assert self.server.address.port == 25565 def test_ping(self): self.socket.receive(bytearray.fromhex("09010000000001C54246")) with patch("mcstatus.server.TCPSocketConnection") as connection: connection.return_value.__enter__.return_value = self.socket latency = self.server.ping(ping_token=29704774, version=47) assert self.socket.flush() == bytearray.fromhex("0F002F093132372E302E302E3163DD0109010000000001C54246") assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty" assert latency >= 0 def test_ping_retry(self): # Use a blank mock for the connection, we don't want to actually create any connections with patch("mcstatus.server.TCPSocketConnection"), patch("mcstatus.server.JavaClient") as java_client: java_client.side_effect = [RuntimeError, RuntimeError, RuntimeError] with pytest.raises(RuntimeError, match=r"^$"): _ = self.server.ping() assert java_client.call_count == 3 def test_status(self): self.socket.receive( bytearray.fromhex( "6D006B7B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2" "26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E38222C2270726F746F" "636F6C223A34377D7D" ) ) with patch("mcstatus.server.TCPSocketConnection") as connection: connection.return_value.__enter__.return_value = self.socket info = self.server.status(version=47) assert self.socket.flush() == bytearray.fromhex("0F002F093132372E302E302E3163DD010100") assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty" assert info.raw == { "description": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.8", "protocol": 47}, } assert info.latency >= 0 def test_status_retry(self): # Use a blank mock for the connection, we don't want to actually create any connections with patch("mcstatus.server.TCPSocketConnection"), patch("mcstatus.server.JavaClient") as java_client: java_client.side_effect = [RuntimeError, RuntimeError, RuntimeError] with pytest.raises(RuntimeError, match=r"^$"): _ = self.server.status() assert java_client.call_count == 3 def test_query(self): socket = SyncDatagramConnection() socket.receive(bytearray.fromhex("090000000035373033353037373800")) socket.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) with patch("mcstatus.server.UDPSocketConnection") as connection: connection.return_value.__enter__.return_value = socket info = self.server.query() conn_bytes = socket.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert info.raw == { "hostname": "A Minecraft Server", "gametype": "SMP", "game_id": "MINECRAFT", "version": "1.8", "plugins": "", "map": "world", "numplayers": "3", "maxplayers": "20", "hostport": "25565", "hostip": "192.168.56.1", } def test_query_retry(self): # Use a blank mock for the connection, we don't want to actually create any connections with patch("mcstatus.server.UDPSocketConnection"), patch("mcstatus.server.QueryClient") as query_client: query_client.side_effect = [RuntimeError, RuntimeError, RuntimeError] with pytest.raises(RuntimeError, match=r"^$"): _ = self.server.query() assert query_client.call_count == 3 def test_lookup_constructor(self): s = JavaServer.lookup("example.org:4444") assert s.address.host == "example.org" assert s.address.port == 4444 @final class TestLegacyServer: def setup_method(self): self.socket = SyncBufferConnection() self.server = LegacyServer("127.0.0.1") def test_default_port(self): assert self.server.address.port == 25565 def test_lookup_constructor(self): s = LegacyServer.lookup("example.org:4444") assert s.address.host == "example.org" assert s.address.port == 4444 def test_status(self): self.socket.receive( bytearray.fromhex( "ff002300a70031000000340037000000" "31002e0034002e003200000041002000" "4d0069006e0065006300720061006600" "74002000530065007200760065007200" "000030000000320030" ) ) with patch("mcstatus.server.TCPSocketConnection") as connection: connection.return_value.__enter__.return_value = self.socket info = self.server.status() assert self.socket.flush() == bytearray.fromhex("fe01fa") assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty" assert info.as_dict() == { "latency": info.latency, "motd": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.4.2", "protocol": 47}, } assert info.latency >= 0 @final class TestAsyncLegacyServer: def setup_method(self): self.socket = AsyncBufferConnection() self.server = LegacyServer("127.0.0.1") async def test_async_lookup_constructor(self): s = await LegacyServer.async_lookup("example.org:3333") assert s.address.host == "example.org" assert s.address.port == 3333 async def test_async_status(self): self.socket.receive( bytearray.fromhex( "ff002300a70031000000340037000000" "31002e0034002e003200000041002000" "4d0069006e0065006300720061006600" "74002000530065007200760065007200" "000030000000320030" ) ) with patch("mcstatus.server.TCPAsyncSocketConnection") as connection: connection.return_value.__aenter__.return_value = self.socket info = await self.server.async_status() assert self.socket.flush() == bytearray.fromhex("fe01fa") assert self.socket.remaining() == 0, "Data is pending to be read, but should be empty" assert info.as_dict() == { "latency": info.latency, "motd": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.4.2", "protocol": 47}, } assert info.latency >= 0 py-mine-mcstatus-341e7a4/tests/utils/000077500000000000000000000000001524603215100175645ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/utils/__init__.py000066400000000000000000000000001524603215100216630ustar00rootroot00000000000000py-mine-mcstatus-341e7a4/tests/utils/test_deprecation.py000066400000000000000000000147521524603215100235030ustar00rootroot00000000000000from __future__ import annotations import re import warnings import pytest from mcstatus._utils.deprecation import _get_project_version, deprecated, deprecation_warn from tests.helpers import patch_project_version LIB_NAME = "mcstatus" def test_invalid_lib_version(): with ( patch_project_version("foo bar"), pytest.warns(match=f"^Failed to parse {LIB_NAME} project version \\(foo bar\\), assuming v0\\.0\\.0$"), ): _ = _get_project_version() def test_epoch_in_lib_version(): with ( patch_project_version("2!1.2.3"), pytest.warns( match=f"^Failed to parse {LIB_NAME} project version, assuming v0\\.0\\.0$", ), ): _ = _get_project_version() @pytest.mark.parametrize("removal_version", ["0.9.0", (0, 9, 0)]) def test_deprecation_warn_produces_error(removal_version: str | tuple[int, int, int]): """Test deprecation_warn with older removal_version than current version produces exception.""" with ( patch_project_version("1.0.0"), pytest.raises( DeprecationWarning, match=r"^test is passed its removal version \(0\.9\.0\)\.$", ), ): deprecation_warn(obj_name="test", removal_version=removal_version) @pytest.mark.parametrize("removal_version", ["1.0.1", (1, 0, 1)]) def test_deprecation_warn_produces_warning(removal_version: str | tuple[int, int, int]): """Test deprecation_warn with newer removal_version than current version produces warning.""" with ( patch_project_version("1.0.0"), pytest.deprecated_call( match=r"^test is deprecated and scheduled for removal in 1\.0\.1\.$", ), ): deprecation_warn(obj_name="test", removal_version=removal_version) def test_deprecation_invalid_removal_version(): """Test deprecation_warn with invalid removal_version.""" pattern = re.escape(r"(\d+)\.(\d+)\.(\d+)") with ( patch_project_version("1.0.0"), pytest.raises( ValueError, match=f"^removal_version must follow regex pattern of: {pattern}$", ), ): deprecation_warn(obj_name="test", removal_version="foo!") def test_deprecation_warn_unknown_version(): """Test deprecation_warn with unknown project version. This could occur if the project wasn't installed as a package. (e.g. when running directly from source, like via a git submodule.) """ with ( patch_project_version(None), pytest.warns(match=f"Failed to get {LIB_NAME} project version", expected_warning=RuntimeWarning), pytest.deprecated_call(match=r"^test is deprecated and scheduled for removal in 1\.0\.0\.$"), ): deprecation_warn(obj_name="test", removal_version="1.0.0") def test_deprecation_decorator_warn(): """Check deprecated decorator triggers a deprecation warning.""" with patch_project_version("1.0.0"): @deprecated(display_name="func", removal_version="1.0.1") def func(x: object) -> object: """Return input value. .. deprecated:: 0.0.1 """ return x with pytest.deprecated_call(match=r"^func is deprecated and scheduled for removal in 1\.0\.1\.$"): assert func(5) == 5 def test_deprecation_decorator_inferred_name(): """Check deprecated decorator properly infers qualified name of decorated object shown in warning.""" with patch_project_version("1.0.0"): @deprecated(removal_version="1.0.1") def func(x: object) -> object: """Return input value. .. deprecated:: 0.0.1 """ return x qualname = r"test_deprecation_decorator_inferred_name\.\.func" with pytest.deprecated_call(match=rf"^{qualname} is deprecated and scheduled for removal in 1\.0\.1\.$"): assert func(5) == 5 def test_deprecation_decorator_missing_docstring_directive(): """Check deprecated decorator validates a docstring contains a deprecation directive.""" with ( patch_project_version("1.0.0"), pytest.raises( ValueError, match=r"^Deprecated object does not contain '\.\. deprecated::' sphinx directive in its docstring$", ), ): @deprecated(display_name="func", removal_version="1.0.1") def func(x: object) -> object: # pyright: ignore[reportUnusedFunction] return x def test_deprecation_decorator_no_docstring_check_opt_out(): """Check deprecated decorator can skip docstring validation when requested.""" with patch_project_version("1.0.0"): @deprecated(display_name="func", removal_version="1.0.1", no_docstring_check=True) def func(x: object) -> object: return x with pytest.deprecated_call(match=r"^func is deprecated and scheduled for removal in 1\.0\.1\.$"): assert func(5) == 5 @pytest.mark.parametrize( ("version", "expected"), [ ("1.2.3", (1, 2, 3)), ("0.0.1", (0, 0, 1)), ("1.0.0", (1, 0, 0)), ("10.20.30", (10, 20, 30)), ("1.2.3rc1", (1, 2, 3)), ("1.2.3-rc1", (1, 2, 3)), ("1.2.3.post1", (1, 2, 3)), ("1.2.3-1", (1, 2, 3)), ("1.2.3.dev4", (1, 2, 3)), ("1.2.3+local", (1, 2, 3)), ("1.2.3rc1.post2.dev3+loc.1", (1, 2, 3)), ], ) def test_project_version_non_normalized_parsing(version: str, expected: tuple[int, int, int]): """Ensure PEP440 release versions get parsed out properly, with non-release components are ignored.""" with patch_project_version(version), warnings.catch_warnings(): warnings.simplefilter("error") # raise warnings as errors (test there are no warnings) assert _get_project_version() == expected @pytest.mark.parametrize( ("version", "expected", "warning"), [ ( "1.2", (1, 2, 0), f"{LIB_NAME} version '1.2' has less than 3 release components; remaining components will become zeroes", ), ( "1.2.3.4", (1, 2, 3), f"{LIB_NAME} version '1.2.3.4' has more than 3 release components; extra components are ignored", ), ], ids=["1.2", "1.2.3.4"], ) def test_project_version_normalizes_release_components( version: str, expected: tuple[int, int, int], warning: str, ): """Ensure release segments normalize to a 3-component version and warn.""" with patch_project_version(version), pytest.warns(RuntimeWarning, match=rf"^{re.escape(warning)}$"): assert _get_project_version() == expected py-mine-mcstatus-341e7a4/tests/utils/test_general.py000066400000000000000000000006651524603215100226210ustar00rootroot00000000000000import pytest from mcstatus._utils.general import or_none @pytest.mark.parametrize( ("a", "b", "result"), [ (None, None, None), (None, "", ""), ("", None, ""), ("a", "b", "a"), ], ) def test_or_none(a: object | None, b: object | None, result: object | None): assert or_none(a, b) == result def test_or_none_many_arguments(): assert or_none(*([None] * 100 + ["value"])) == "value" py-mine-mcstatus-341e7a4/tests/utils/test_retry.py000066400000000000000000000025601524603215100223450ustar00rootroot00000000000000import pytest from mcstatus._utils.retry import retry from tests.protocol.helpers import async_decorator def test_sync_success(): x = -1 @retry(tries=2) def func(): nonlocal x x += 1 return 5 / x y = func() assert x == 1 assert y == 5 def test_sync_fail(): x = -1 @retry(tries=2) def func(): nonlocal x x += 1 if x == 0: raise OSError("First error") if x == 1: raise RuntimeError("Second error") # We should get the last exception on failure (not OSError) with pytest.raises(RuntimeError, match=r"^Second error$"): func() def test_async_success(): x = -1 @retry(tries=2) async def func(): # should be async without await # ruff: ignore[unused-async] nonlocal x x += 1 return 5 / x y = async_decorator(func)() assert x == 1 assert y == 5 def test_async_fail(): x = -1 @retry(tries=2) async def func(): # should be async without await # ruff: ignore[unused-async] nonlocal x x += 1 if x == 0: raise OSError("First error") if x == 1: raise RuntimeError("Second error") # We should get the last exception on failure (not OSError) with pytest.raises(RuntimeError, match=r"^Second error$"): async_decorator(func)() py-mine-mcstatus-341e7a4/uv.lock000066400000000000000000006235221524603215100166000ustar00rootroot00000000000000version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", "python_full_version < '3.12'", ] [[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 = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] [[package]] name = "asyncio-dgram" version = "3.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/57/92/531067e931af346b13cab79ab9be2619b043e54d611f413809e71119e093/asyncio_dgram-3.0.0.tar.gz", hash = "sha256:bd0937807a44451d799573b32400702187fd0bba6136f7cf306e9327c0c20f3e", size = 26500, upload-time = "2026-01-21T19:40:18.547Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/af/a1/ad5d53a50d07b4da934c5ff7db8fc3cfd091660b0f78174499644c72523f/asyncio_dgram-3.0.0-py3-none-any.whl", hash = "sha256:a4113061e6a7fbeee928d49c56cb61b68ca4a2fbee37f7e97280bbc72323ba8e", size = 7092, upload-time = "2026-01-21T19:40:17.309Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "backports-asyncio-runner" version = "1.2.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] [[package]] name = "basedpyright" version = "1.39.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodejs-wheel-binaries" }, ] sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" }, ] [[package]] name = "beautifulsoup4" version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] name = "certifi" version = "2026.7.22" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] name = "cfgv" version = "3.5.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" version = "3.5.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[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.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/fc/fbd92ecbe5efbe68cf9e708d858570ebd33f0761600358948882f1a2a96b/coverage-7.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36aed4951aedf04cbe9465e76f8e71219980a52b73d07afe69746cba6ba7b97a", size = 222890, upload-time = "2026-08-28T21:50:37.361Z" }, { url = "https://files.pythonhosted.org/packages/c6/54/16d2a7602ddf169353344e135541731cc24c7c3ef0001b0302f4d1a3de1e/coverage-7.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb953835dbfa6d641ac3943e0986bc680f8abbdc2985af15b46c54985347146a", size = 223415, upload-time = "2026-08-28T21:50:40.747Z" }, { url = "https://files.pythonhosted.org/packages/52/a1/15a36a42b35f6dd66214701c4f797856a9e42d12d85f441101eeb349404c/coverage-7.16.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97051c4903689b1afedc2a354d6118223051e03588078b53048603bda9014577", size = 250146, upload-time = "2026-08-28T21:50:42.509Z" }, { url = "https://files.pythonhosted.org/packages/8a/bc/677f6363054d2de71fd0ca2071a796e3cf7cf82f8046933b34f2f91eb031/coverage-7.16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:770d4244c423dcafb5c31db393f429fe952b1bba23bbff7cc3886f8133769ba5", size = 251977, upload-time = "2026-08-28T21:50:44.137Z" }, { url = "https://files.pythonhosted.org/packages/40/fc/9be462bb9257d84e3cc7517dc118c364db0eabdd6cb42272bb8667abedce/coverage-7.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e7de0cb87960c6c9b5cad760068dab767b2b49a3b9376e1992c1e2691a015e", size = 253840, upload-time = "2026-08-28T21:50:45.822Z" }, { url = "https://files.pythonhosted.org/packages/24/cd/dc003310b876c793c88f8dcf64ca52db244e4b6f96772251b1814fbb0653/coverage-7.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c2c45ee1853668f0ea1a0ddff396421c9dc5ad25a56bfb94a895970c2d8e7c2", size = 255756, upload-time = "2026-08-28T21:50:47.351Z" }, { url = "https://files.pythonhosted.org/packages/c4/f2/7a0a3c57e488b24d3ed560fc0e449c3e94fe8da0b59ef8dd00b2581c813a/coverage-7.16.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6b2b9599e7513b0a9c5bf0357f9f8deaa4c2c821025b0693d420e6602748981", size = 250811, upload-time = "2026-08-28T21:50:48.974Z" }, { url = "https://files.pythonhosted.org/packages/f2/1d/fd0cffd02a34eec7b92cfa4089a9d82e95390facebd56e5603de780b4727/coverage-7.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fde65e0ea945920265dfe4a2108fc45eee2e2ea3d9c3073af6373ff9836aa71", size = 251882, upload-time = "2026-08-28T21:50:50.516Z" }, { url = "https://files.pythonhosted.org/packages/97/b1/82159b5ab545209764eb3eb0f06e315e9a2fd975a72bbf6da48d5deb6e76/coverage-7.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:78103e79f9378cb0e43ddaa728629a373c070df903c5dfa98b63ba2cfb4e8c42", size = 249886, upload-time = "2026-08-28T21:50:52.304Z" }, { url = "https://files.pythonhosted.org/packages/d3/91/ad689bb219fc78eaea8ff2b2212fa6202d4b716a65a533387183bb7a78ad/coverage-7.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e40e323711b485592354069b1c027ef879cc2d11657eac09a6e5ad0b49ab7406", size = 253698, upload-time = "2026-08-28T21:50:53.832Z" }, { url = "https://files.pythonhosted.org/packages/4a/57/8eef40eb196d3fa1c0bbd99466119a40f70e5f2242eaa8f8852f98f4d985/coverage-7.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c94ef980f7b94d9dab9dac076d44ca706654cd51bad19734e029084adf528c8e", size = 250158, upload-time = "2026-08-28T21:50:55.64Z" }, { url = "https://files.pythonhosted.org/packages/0f/8b/0c0240eb6917c81ea21180bbc098bc01c624868bb917e9a10fffed23b970/coverage-7.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b37ad5cbb77776f446e1b55b461eec2eef5c3e7130c72dc0e1447c3a9da2d199", size = 250759, upload-time = "2026-08-28T21:50:57.272Z" }, { url = "https://files.pythonhosted.org/packages/2f/d4/6b4986aedfaa69a4607f24cf127527dbab4d57bfbeef4fffa126a5dbad6a/coverage-7.16.0-cp310-cp310-win32.whl", hash = "sha256:9421dde689e68d9fd2b6cd7d8c4498e79b5431467b6298517e3f3e60fdbe80a7", size = 224939, upload-time = "2026-08-28T21:50:58.871Z" }, { url = "https://files.pythonhosted.org/packages/90/b4/43cdf444ace1c30c15446b143fa79d4bdd756cfdfce4c63f10c7697ba957/coverage-7.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:81d63b68b26304e3668edb103311c17fe13c2ed1c7fe973309819f27bf61c5b8", size = 225568, upload-time = "2026-08-28T21:51:00.389Z" }, { url = "https://files.pythonhosted.org/packages/53/d2/c76bf165ff01664ca8b1ca7f2b2b5f311353d3959dbac1187dd21c6cc7f8/coverage-7.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:22d8802827404be32f5a4d6ddc037f6fa0074b7d06702c0224cb598def8b665d", size = 223019, upload-time = "2026-08-28T21:51:02.021Z" }, { url = "https://files.pythonhosted.org/packages/16/7d/a47cebf71cb789b6e25de07035d350bff110d02f9c28bf32f92b4c818874/coverage-7.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a739bf08cdca0fad51b73322e4fade0102dd87794e278450b5ee87ef827954db", size = 223524, upload-time = "2026-08-28T21:51:03.632Z" }, { url = "https://files.pythonhosted.org/packages/51/b3/42e46d7e247ba33758156a0cc88dc64715f7e7b04640fbe430c4da437ab1/coverage-7.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f99d12f8234c00b88b8077fedf288b25c77f746de312053b7db90fa756ecbdb3", size = 253934, upload-time = "2026-08-28T21:51:05.365Z" }, { url = "https://files.pythonhosted.org/packages/9a/27/ade10badacc00076854f0c5086fcf8975bb1a379d5288b587509e6ee9763/coverage-7.16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7cae7715afa51dd7c9c42e6603bb46daf424c3449fdf06519cc658aa8d46e2e4", size = 255846, upload-time = "2026-08-28T21:51:06.922Z" }, { url = "https://files.pythonhosted.org/packages/c5/50/38e5d8cf45af5db7419e9580bba4017113f8f1e2697cb6c52213bf7e7e40/coverage-7.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55957d350452017f523b9b03ffac078f9a214e23c04a3d0a674569203550c719", size = 257953, upload-time = "2026-08-28T21:51:08.51Z" }, { url = "https://files.pythonhosted.org/packages/9b/bb/2f44b99723d0306095dacdf90f994631e299ff8f087a384b42ecc2d1ccb9/coverage-7.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b670bd5fa93d9b6855b2837217b45a90863118e2de5e9e033aebd46d07cd08d3", size = 259915, upload-time = "2026-08-28T21:51:10.155Z" }, { url = "https://files.pythonhosted.org/packages/ab/7d/3f1c312944d88b2d3cae8af72007c15dcf5f92bda6da6d433c2d5f050ee7/coverage-7.16.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe5aa402d02318db2f41e471320b2ecca6085b8f595a034c037085732e49c04a", size = 254028, upload-time = "2026-08-28T21:51:11.845Z" }, { url = "https://files.pythonhosted.org/packages/7e/f6/52a7e26baeeca7f3114b15da5e840bebcfe6491eb234f6922d33c79ee8fc/coverage-7.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fddd26ed9a2527a7e23f7e4c1fd0734c4a5b45f77b261da1c536b20a7d2e6f0c", size = 255648, upload-time = "2026-08-28T21:51:13.614Z" }, { url = "https://files.pythonhosted.org/packages/c2/d1/0673e78d9ca29d56f663623791338647753c673f0bc964e860086da07bce/coverage-7.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b2af58ecdcec37fe633d4865fccbc8c00d8aa3b31c099bcacb2720c9a0be6ab9", size = 253708, upload-time = "2026-08-28T21:51:15.19Z" }, { url = "https://files.pythonhosted.org/packages/6c/23/b74c87828369059415b20884b6f48260f049bff750d6eb454be8554732ab/coverage-7.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a3cd34b9025d62180ce2b5dae8a985bfa6cb8c05ecd57fd34ffc1ff751b5a74d", size = 257479, upload-time = "2026-08-28T21:51:16.988Z" }, { url = "https://files.pythonhosted.org/packages/a9/b4/09e172472c45a956e226dddf82d449f245764208b7cea47b32a73df955a3/coverage-7.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ebaf39dd13f8af65fe5f0316b81046228ef4d91d3c3766192b418753649896d6", size = 253428, upload-time = "2026-08-28T21:51:18.803Z" }, { url = "https://files.pythonhosted.org/packages/62/22/e378e4f7ffa290ea4775b34e319fa182640bba650a2c6781af791b66b79a/coverage-7.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5dad64d9c17cb1983adef07998e6e2e1cf870a156f1ea80f81ce1970f4c545ce", size = 254337, upload-time = "2026-08-28T21:51:20.785Z" }, { url = "https://files.pythonhosted.org/packages/51/6f/9a6ca653d86e46c3383a905f726a28bcf7bb2528088794d30a53687b381c/coverage-7.16.0-cp311-cp311-win32.whl", hash = "sha256:38b8e1e73750b8965d1154ed733f5303acd4e24ee2d5ee872bb1bfab744a31ce", size = 225103, upload-time = "2026-08-28T21:51:22.685Z" }, { url = "https://files.pythonhosted.org/packages/08/0c/6d4627be89ac02f579d88806875a5d6e328c59d7d79c594643c7a4460ef6/coverage-7.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc12e5e32acdd62fe5895939695579560639853219288519685c75b7e968d63a", size = 225577, upload-time = "2026-08-28T21:51:24.334Z" }, { url = "https://files.pythonhosted.org/packages/f2/3d/d7be38564d00a17775426685776b4bf18e8a6048a085eccf65d75eb0fa5a/coverage-7.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:17fc3628f99812fec24f40092af34c1c73274d331babab3d1d768a75de650cf7", size = 225126, upload-time = "2026-08-28T21:51:26.101Z" }, { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, ] [package.optional-dependencies] toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] name = "distlib" version = "0.4.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] [[package]] name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] name = "docs" version = "0.0.0" source = { directory = "docs" } dependencies = [ { name = "furo" }, { name = "m2r2" }, { name = "packaging" }, { name = "sphinx" }, { name = "sphinx-autodoc-typehints" }, { name = "uv-dynamic-versioning" }, ] [package.metadata] requires-dist = [ { name = "furo", specifier = ">=2025.7.19" }, { name = "m2r2", specifier = "~=0.3.4" }, { name = "packaging", specifier = "~=26.0" }, { name = "sphinx", specifier = "~=9.1.0" }, { name = "sphinx-autodoc-typehints", specifier = "~=3.13.0" }, { name = "uv-dynamic-versioning", specifier = "~=0.14.0" }, ] [[package]] name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] name = "dunamai" version = "1.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/18/020d3b27a10450ddb11429f637404e8ea67ecf4d9fd999d4f1d553f25506/dunamai-1.26.2.tar.gz", hash = "sha256:84ea45eddf9bb4b40df7610b1b22a03137365e6257dbf9d7b72128fdccca564c", size = 46536, upload-time = "2026-08-02T03:32:50.276Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f0/31/2aaabe7d03f395c7b6d955f09a4d1440a2c49920abea42fde88cacc2ef97/dunamai-1.26.2-py3-none-any.whl", hash = "sha256:4234be3a90c3ec13ecf75d94a2248068e5a3018e8112892fcd9f3a1f287c9c32", size = 27478, upload-time = "2026-08-02T03:32:49.348Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "filelock" version = "3.32.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6d/30/03b03951873a1a0ffc7e8ca0e10c15597b59e8d0e39260704cd2ea087bc4/filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30", size = 222126, upload-time = "2026-08-23T17:37:55.363Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/01/a4/9b63d595d748e3aff8812b65eacc1a2c4bd90b7c2012e08e72373b4835eb/filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd", size = 99864, upload-time = "2026-08-23T17:37:53.913Z" }, ] [[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 = "hatchling" version = "1.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pathspec" }, { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomlkit" }, { name = "trove-classifiers" }, ] sdist = { url = "https://files.pythonhosted.org/packages/69/08/33331757185504aae48b8d9bd78cec03a76e3aecfb52e549d05a2347c0dd/hatchling-1.32.0.tar.gz", hash = "sha256:0bdbde4a52b06c37e3eca395f85a762bf0ef06fe374fd8ae429dc6be10230f5f", size = 57783, upload-time = "2026-08-11T05:03:44.114Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a9/84/1798b6d85ecde0e31546004efd25c5de1b1f49250644a60cce460e12593a/hatchling-1.32.0-py3-none-any.whl", hash = "sha256:0e17c9c3b9aa7c625acc8d0f5b622f107d5049af9ecf5ada4de1aada5be7cdbc", size = 78435, upload-time = "2026-08-11T05:03:42.644Z" }, ] [[package]] name = "identify" version = "2.6.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" version = "3.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] name = "imagesize" version = "2.0.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fb/5e/513ff06670c84e7b9887c1fdf61b2d42b4f574a831f2f1d2222023049d8a/imagesize-2.0.1.tar.gz", hash = "sha256:b2ba6a4dea487a7ebcd53248d3476aca449d30db12a2dde5e0c5ca9624fd77e5", size = 1883774, upload-time = "2026-08-24T12:35:19.13Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl", hash = "sha256:ea0c9a0384df69ed86a943a15cde37d0360b82491b3910dc2215e202e62b5b02", size = 14794, upload-time = "2026-08-24T12:35:12.548Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[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 = "m2r2" version = "0.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docutils" }, { name = "mistune" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/a9/ab03ff21972cfb2e6ea766b1dc015c2c081af70f8f956806e948c62044ba/m2r2-0.3.4.tar.gz", hash = "sha256:e278f5f337e9aa7b2080fcc3e94b051bda9615b02e36c6fb3f23ff019872f043", size = 17082, upload-time = "2025-05-01T16:55:53.244Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a9/50/5b1d70de548fb1e11e15ea9ebec0480365462d202f98b66ad7e154284327/m2r2-0.3.4-py3-none-any.whl", hash = "sha256:1a445514af8a229496bfb1380c52da8dd38313e48600359ee92b2c9d2e4df34a", size = 11199, upload-time = "2025-05-01T16:55:51.87Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "mcstatus" source = { editable = "." } dependencies = [ { name = "asyncio-dgram" }, { name = "dnspython" }, ] [package.dev-dependencies] dev = [ { name = "poethepoet" }, ] docs = [ { name = "docs", marker = "python_full_version >= '3.12'" }, ] lint = [ { name = "basedpyright" }, { name = "pre-commit" }, { name = "ruff" }, { name = "typing-extensions" }, ] release = [ { name = "hatchling" }, { name = "uv-dynamic-versioning" }, ] test = [ { name = "coverage" }, { name = "hatchling" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-rerunfailures" }, { name = "typing-extensions" }, { name = "uv-dynamic-versioning" }, ] [package.metadata] requires-dist = [ { name = "asyncio-dgram", specifier = ">=2.1.2" }, { name = "dnspython", specifier = ">=2.4.2" }, ] [package.metadata.requires-dev] dev = [{ name = "poethepoet", specifier = "~=0.48.0" }] docs = [{ name = "docs", marker = "python_full_version >= '3.12'", directory = "docs" }] lint = [ { name = "basedpyright", specifier = "~=1.39.2" }, { name = "pre-commit", specifier = "~=4.6.0" }, { name = "ruff", specifier = "~=0.16.0" }, { name = "typing-extensions", specifier = "~=4.16.0" }, ] release = [ { name = "hatchling", specifier = "~=1.32.0" }, { name = "uv-dynamic-versioning", specifier = "~=0.14.0" }, ] test = [ { name = "coverage", specifier = "~=7.16.0" }, { name = "hatchling" }, { name = "pytest", specifier = "~=9.1.0" }, { name = "pytest-asyncio", specifier = "~=1.4.0" }, { name = "pytest-cov", specifier = "~=7.1.0" }, { name = "pytest-rerunfailures", specifier = "~=16.1" }, { name = "typing-extensions", specifier = "~=4.16.0" }, { name = "uv-dynamic-versioning" }, ] [[package]] name = "mistune" version = "0.8.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/2d/a4/509f6e7783ddd35482feda27bc7f72e65b5e7dc910eca4ab2164daf9c577/mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", size = 58322, upload-time = "2018-10-11T06:59:27.908Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/09/ec/4b43dae793655b7d8a25f76119624350b4d65eb663459eb9603d7f1f0345/mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4", size = 16220, upload-time = "2018-10-11T06:59:26.044Z" }, ] [[package]] name = "nodeenv" version = "1.10.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "nodejs-wheel-binaries" version = "24.19.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, ] [[package]] name = "packaging" version = "26.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pastel" version = "0.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "platformdirs" version = "4.11.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ea/06/cf1564dcc2e2261c8c8c6c05628dc8b418943bdae2a4e58640ceb2f770fa/platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173", size = 34823, upload-time = "2026-08-27T21:36:37.46Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/12/6f3fcd5067a9cbf4f8664b32957973498da8b083455203c8d9cab83a725c/platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b", size = 23900, upload-time = "2026-08-27T21:36:36.227Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "poethepoet" version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/59/8d/d7c9455b15f8d2d7ce57e7b71a8ef8d02d9992ae4283c9777120620c9022/poethepoet-0.48.0-py3-none-any.whl", hash = "sha256:98da6096d060f49b8d84034770265863fb7dc92a40233b7694b9d216ac68737d", size = 185808, upload-time = "2026-07-05T21:48:28.601Z" }, ] [[package]] name = "pre-commit" version = "4.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, { name = "nodeenv" }, { name = "pyyaml" }, { name = "virtualenv" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" }, ] [[package]] name = "pygments" version = "2.21.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pytest" version = "9.1.1" 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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[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 = "pytest-rerunfailures" version = "16.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ed/63/0114e45d4b2fcd5f6297dac655c067b47de28be4d33e088f200f0f2c4c28/pytest_rerunfailures-16.6.tar.gz", hash = "sha256:29dbfee46f542073c888e0ed4e81c51e15b9096f49a299eb1a759629c601684a", size = 42806, upload-time = "2026-08-17T07:11:00.447Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5d/5e/1e994889673d7a0da11651f17ef789b6c83bfe349f29f871873dd3802445/pytest_rerunfailures-16.6-py3-none-any.whl", hash = "sha256:6af2d1ebd6e5cb79666ac408942cd6a0672a49fefd8523664770718560795e13", size = 19137, upload-time = "2026-08-17T07:10:59.121Z" }, ] [[package]] name = "python-discovery" version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, ] sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] [[package]] name = "requests" version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] name = "roman-numerals" version = "4.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, ] [[package]] name = "ruff" version = "0.16.5" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]] name = "snowballstemmer" version = "3.1.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, ] [[package]] name = "soupsieve" version = "2.9.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/69/99/a6ca3beb3ccacb41fb3321d8a60e5566f9e6467601ef8eba6a17e1b89778/soupsieve-2.9.2.tar.gz", hash = "sha256:4a55d8cf158a9c2e587fa4922f1bbb91d68ac829e2d6f25403a85747c71daf74", size = 122445, upload-time = "2026-08-07T00:57:24.801Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/eb/dc/ad025c1ee131eba60c69f4dd5779b18fcf1e6b21a343e2162a84d5d133c7/soupsieve-2.9.2-py3-none-any.whl", hash = "sha256:8089a26fd974ca7a1f30276d3d8492ab266ab15af581642dfe8aa162e0c1c823", size = 37370, upload-time = "2026-08-07T00:57:23.524Z" }, ] [[package]] name = "sphinx" version = "9.1.0" 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 = "roman-numerals" }, { name = "snowballstemmer" }, { name = "sphinxcontrib-applehelp" }, { name = "sphinxcontrib-devhelp" }, { name = "sphinxcontrib-htmlhelp" }, { name = "sphinxcontrib-jsmath" }, { name = "sphinxcontrib-qthelp" }, { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, ] [[package]] name = "sphinx-autodoc-typehints" version = "3.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/93/87/71b5530a4657d5dda36742907d6ba77c63db724a9099eda5e114a62016b4/sphinx_autodoc_typehints-3.13.4.tar.gz", hash = "sha256:9429680faa192fec9797edb9575923177f9d2ab277481d561d4cbd4aeb9cd472", size = 92020, upload-time = "2026-08-24T15:37:44.181Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/bc/10/bc3e16cda1241b45f34bbec5e3728d386e45c831798d0e45da57d2023aae/sphinx_autodoc_typehints-3.13.4-py3-none-any.whl", hash = "sha256:05c9fa3bc312cb576a16a4b2e53459c170e540665687049a0d142a83736f5209", size = 45801, upload-time = "2026-08-24T15:37:43.004Z" }, ] [[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 = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "tomlkit" version = "0.15.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] [[package]] name = "trove-classifiers" version = "2026.6.1.19" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c2/e3/7ca82ee24c82d344584abd5b8637b3bd056f2900226e8d82fc22f1184b92/trove_classifiers-2026.6.1.19.tar.gz", hash = "sha256:c5132b4b61a829d11cfbd2d72e97f20a45ed6edb95e45c5efdeb5e00836b2745", size = 17059, upload-time = "2026-06-01T19:41:34.649Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl", hash = "sha256:ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3", size = 14211, upload-time = "2026-06-01T19:41:33.434Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] name = "uv-dynamic-versioning" version = "0.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dunamai" }, { name = "hatchling" }, { name = "jinja2" }, { name = "tomlkit" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/fa500ee29af69cfeeea5ff6d6597919f1989b2e3f1a236c3006bdb21d320/uv_dynamic_versioning-0.14.1.tar.gz", hash = "sha256:8642db686ce5c50417035e7a257ac73b7e5c3a7a32c33e45bd7e36ba22eeb648", size = 42153, upload-time = "2026-08-18T10:50:14.069Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/8c/98ca22d2e282d2c0a4d1fad3faece26c9cb695d23f5d255ae93398d594ca/uv_dynamic_versioning-0.14.1-py3-none-any.whl", hash = "sha256:f9db710cc2ce9dccf06fab958ad10c45cae43cff70e3eccaa89365b040e98306", size = 11083, upload-time = "2026-08-18T10:50:13.095Z" }, ] [[package]] name = "virtualenv" version = "21.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/79/41/c3f34799487924f2a6f43b8a8b7acd345a6c61aac2211d4bced8621ca4f1/virtualenv-21.7.7.tar.gz", hash = "sha256:6874376f99ba6b8d4e3ee8bde67f9285412400c7d5b29ba41ee6daa5e0221bdc", size = 5347022, upload-time = "2026-08-28T18:59:50.093Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/95/f8/dcec8dc767b812193316c7debed1e2b53e586e59c267bfe517688ff90275/virtualenv-21.7.7-py3-none-any.whl", hash = "sha256:67a6a68fef3ad8ca16b8b89f33fd8f97996cc0bf0db31629d07ecf8dec539a2c", size = 5324620, upload-time = "2026-08-28T18:59:48.056Z" }, ]