Repository: py-mine/mcstatus Branch: master Commit: 87ccc41cc588 Files: 101 Total size: 407.1 KB Directory structure: gitextract_h1f5vhjq/ ├── .coveragerc ├── .github/ │ ├── CODEOWNERS │ ├── renovate.json5 │ └── workflows/ │ ├── main.yml │ ├── publish.yml │ ├── status_embed.yml │ ├── unit-tests.yml │ └── validation.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs/ │ ├── Makefile │ ├── api/ │ │ ├── basic.rst │ │ ├── internal.rst │ │ └── motd_parsing.rst │ ├── conf.py │ ├── examples/ │ │ ├── code/ │ │ │ ├── ping_as_java_and_bedrock_in_one_time.py │ │ │ ├── ping_many_servers_at_once.py │ │ │ └── player_list_from_query_with_fallback_on_status.py │ │ ├── examples.rst │ │ ├── ping_as_java_and_bedrock_in_one_time.rst │ │ ├── ping_many_servers_at_once.rst │ │ └── player_list_from_query_with_fallback_on_status.rst │ ├── index.rst │ ├── pages/ │ │ ├── contributing.rst │ │ ├── faq.rst │ │ └── versioning.rst │ └── pyproject.toml ├── mcstatus/ │ ├── __init__.py │ ├── __main__.py │ ├── _compat/ │ │ ├── README.md │ │ ├── __init__.py │ │ ├── forge_data.py │ │ ├── motd_transformers.py │ │ └── status_response.py │ ├── _net/ │ │ ├── __init__.py │ │ ├── address.py │ │ └── dns.py │ ├── _protocol/ │ │ ├── __init__.py │ │ ├── bedrock_client.py │ │ ├── connection.py │ │ ├── java_client.py │ │ ├── legacy_client.py │ │ └── query_client.py │ ├── _utils/ │ │ ├── __init__.py │ │ ├── deprecation.py │ │ ├── general.py │ │ └── retry.py │ ├── motd/ │ │ ├── __init__.py │ │ ├── _simplifies.py │ │ ├── _transformers.py │ │ └── components.py │ ├── py.typed │ ├── responses/ │ │ ├── __init__.py │ │ ├── _raw.py │ │ ├── base.py │ │ ├── bedrock.py │ │ ├── forge.py │ │ ├── java.py │ │ ├── legacy.py │ │ └── query.py │ └── server.py ├── pyproject.toml └── tests/ ├── __init__.py ├── helpers.py ├── motd/ │ ├── __init__.py │ ├── conftest.py │ ├── test_components.py │ ├── test_motd.py │ ├── test_simplifies.py │ └── test_transformers.py ├── net/ │ ├── __init__.py │ └── test_address.py ├── protocol/ │ ├── __init__.py │ ├── test_async_support.py │ ├── test_bedrock_client.py │ ├── test_connection.py │ ├── test_java_client.py │ ├── test_java_client_async.py │ ├── test_legacy_client.py │ ├── test_query_client.py │ ├── test_query_client_async.py │ └── test_timeout.py ├── responses/ │ ├── __init__.py │ ├── conftest.py │ ├── test_base.py │ ├── test_bedrock.py │ ├── test_forge_data.py │ ├── test_java.py │ ├── test_legacy.py │ └── test_query.py ├── test_cli.py ├── test_compat.py ├── test_server.py └── utils/ ├── __init__.py ├── test_deprecation.py ├── test_general.py └── test_retry.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [report] exclude_lines = pragma: no cover ((t|typing)\.)?TYPE_CHECKING ^\s\.\.\.\s$ def __repr__ class .*\bProtocol\): @(abc\.)?abstractmethod raise NotImplementedError if __name__ == "__main__": ================================================ FILE: .github/CODEOWNERS ================================================ # Devops & CI workflows .github/dependabot.yml @ItsDrike .github/workflows/** @ItsDrike .pre-commit-config.yaml @ItsDrike ================================================ FILE: .github/renovate.json5 ================================================ { "$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, }, } ================================================ FILE: .github/workflows/main.yml ================================================ name: CI on: push: branches: - master 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 ================================================ FILE: .github/workflows/publish.yml ================================================ --- 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 master. 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. - master 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@v6 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@v7 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 ================================================ FILE: .github/workflows/status_embed.yml ================================================ name: 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 }} ================================================ FILE: .github/workflows/unit-tests.yml ================================================ name: 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@v6 - name: Setup uv uses: astral-sh/setup-uv@v7 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 ================================================ FILE: .github/workflows/validation.yml ================================================ name: 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@v6 - name: Setup uv uses: astral-sh/setup-uv@v7 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 pyright 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@v5 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,pyright,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 pyright type checker run: pyright . - name: Check UV Lockfile run: uv lock --check ================================================ FILE: .gitignore ================================================ .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 ================================================ FILE: .pre-commit-config.yaml ================================================ ci: 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: pyright name: Pyright description: Run pyright type checker entry: pyright language: system types: [python] pass_filenames: false # pyright 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 ================================================ FILE: .readthedocs.yaml ================================================ version: 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 ================================================ FILE: CONTRIBUTING.md ================================================ ## Contributing See [the documentation page](https://mcstatus.readthedocs.io/en/stable/pages/contributing/). The documentation itself is built from the docs/ directory. ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================ # 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. ================================================ FILE: docs/Makefile ================================================ # 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) ================================================ FILE: docs/api/basic.rst ================================================ Basic 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 `! ================================================ FILE: docs/api/internal.rst ================================================ Internal 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: ================================================ FILE: docs/api/motd_parsing.rst ================================================ MOTD 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 ================================================ FILE: docs/conf.py ================================================ """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 # noqa: E731 sys.path.insert(0, os.path.abspath("..")) # noqa: PTH100 # -- 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 # noqa: PLC0415 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. """ # noqa: D401 # imperative mood from mcstatus._net.address import _AddressBase # noqa: PLC0415 del _AddressBase.__new__.__doc__ mock_autodoc() delete_doc_for_address_base() ================================================ FILE: docs/examples/code/ping_as_java_and_bedrock_in_one_time.py ================================================ from __future__ import annotations import asyncio from mcstatus import BedrockServer, JavaServer from mcstatus.responses.bedrock import BedrockStatusResponse from mcstatus.responses.java import JavaStatusResponse async def status(host: str) -> JavaStatusResponse | BedrockStatusResponse: """Get status from server, which can be Java or Bedrock. The function will ping server as Java and as Bedrock in one time, and return the first response. """ success_task = await handle_exceptions( *( await asyncio.wait( { asyncio.create_task(handle_java(host), name="Get status as Java"), asyncio.create_task(handle_bedrock(host), name="Get status as Bedrock"), }, return_when=asyncio.FIRST_COMPLETED, ) ) ) if success_task is None: raise ValueError("No tasks were successful. Is server offline?") return success_task.result() async def handle_exceptions(done: set[asyncio.Task], pending: set[asyncio.Task]) -> asyncio.Task | None: """Handle exceptions from tasks. Also, cancel all pending tasks, if found the correct one. """ if len(done) == 0: raise ValueError("No tasks was given to `done` set.") for i, task in enumerate(done): if task.exception() is not None: if len(pending) == 0: continue if i == len(done) - 1: # firstly check all items from `done` set, and then handle pending set return await handle_exceptions(*(await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED))) else: for pending_task in pending: pending_task.cancel() return task return None async def handle_java(host: str) -> JavaStatusResponse: """Wrap mcstatus, to compress lookup and status into one function.""" return await (await JavaServer.async_lookup(host)).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 return await BedrockServer.lookup(host).async_status() ================================================ FILE: docs/examples/code/ping_many_servers_at_once.py ================================================ import 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 here your ips! asyncio.run(ping_ips(ips)) if __name__ == "__main__": main() ================================================ FILE: docs/examples/code/player_list_from_query_with_fallback_on_status.py ================================================ from 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])) ================================================ FILE: docs/examples/examples.rst ================================================ Examples ======== 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! ================================================ FILE: docs/examples/ping_as_java_and_bedrock_in_one_time.rst ================================================ Ping 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}") ================================================ FILE: docs/examples/ping_many_servers_at_once.rst ================================================ Ping 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 ================================================ FILE: docs/examples/player_list_from_query_with_fallback_on_status.rst ================================================ Get player list from query, while falling back on status ======================================================== .. literalinclude:: code/player_list_from_query_with_fallback_on_status.py ================================================ FILE: docs/index.rst ================================================ .. 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` ================================================ FILE: docs/pages/contributing.rst ================================================ Contributing ============ 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 pyright (pylance) 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 ================================================ FILE: docs/pages/faq.rst ================================================ Frequently 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 `_. ================================================ FILE: docs/pages/versioning.rst ================================================ Versioning 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. ================================================ FILE: docs/pyproject.toml ================================================ [project] name = "docs" version = "0.0.0" license = "Apache-2.0" requires-python = ">=3.12" dependencies = [ "sphinx~=9.1.0", "sphinx-autodoc-typehints~=3.10.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 ================================================ FILE: mcstatus/__init__.py ================================================ from mcstatus.server import BedrockServer, JavaServer, LegacyServer, MCServer __all__ = [ "BedrockServer", "JavaServer", "LegacyServer", "MCServer", ] ================================================ FILE: mcstatus/__main__.py ================================================ # ruff: noqa: T201 # usage of `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): 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. ping_exc = None try: return server.ping(tries=1) except Exception as e: # noqa: BLE001 # blindly catching Exception 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: # noqa: BLE001 # blindly catching Exception exn = exn or e try: if isinstance(server, JavaServer): query_res = server.query(tries=1) except Exception as e: # noqa: BLE001 # blindly catching Exception 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()) ================================================ FILE: mcstatus/_compat/README.md ================================================ # 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. ================================================ FILE: mcstatus/_compat/__init__.py ================================================ ================================================ FILE: mcstatus/_compat/forge_data.py ================================================ from 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", ) ================================================ FILE: mcstatus/_compat/motd_transformers.py ================================================ from mcstatus._utils import deprecation_warn from mcstatus.motd._transformers import ( AnsiTransformer, HtmlTransformer, MinecraftTransformer, PlainTransformer, _BaseTransformer as BaseTransformer, _NothingTransformer as NothingTransformer, ) __all__ = [ "AnsiTransformer", "BaseTransformer", "HtmlTransformer", "MinecraftTransformer", "NothingTransformer", "PlainTransformer", ] deprecation_warn( obj_name="mcstatus.motd.transformers", removal_version="13.0.0", extra_msg="MOTD Transformers are no longer a part of mcstatus public API", ) ================================================ FILE: mcstatus/_compat/status_response.py ================================================ from mcstatus._utils import deprecation_warn from mcstatus.responses import ( BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion, BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion, JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, ) __all__ = [ "BaseStatusPlayers", "BaseStatusResponse", "BaseStatusVersion", "BedrockStatusPlayers", "BedrockStatusResponse", "BedrockStatusVersion", "JavaStatusPlayer", "JavaStatusPlayers", "JavaStatusResponse", "JavaStatusVersion", ] deprecation_warn( obj_name="mcstatus.status_response", removal_version="13.0.0", replacement="mcstatus.responses", ) ================================================ FILE: mcstatus/_net/__init__.py ================================================ ================================================ FILE: mcstatus/_net/address.py ================================================ from __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: # noqa: ARG002 # unused arguments # 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) ================================================ FILE: mcstatus/_net/dns.py ================================================ from __future__ import annotations 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.SRV import SRV as SRVRecordAnswer # noqa: N811 # constant imported as non constant (it's class) __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 record to given hostname. :param hostname: The address to resolve for. :return: The resolved IP address from the A 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` """ answers = dns.resolver.resolve(hostname, RdataType.A, lifetime=lifetime, search=True) # There should only be one answer here, though in case the server # does actually point to multiple IPs, we just pick the first one answer = cast("ARecordAnswer", answers[0]) ip = str(answer).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. """ answers = await dns.asyncresolver.resolve(hostname, RdataType.A, lifetime=lifetime, search=True) # There should only be one answer here, though in case the server # does actually point to multiple IPs, we just pick the first one answer = cast("ARecordAnswer", answers[0]) ip = str(answer).rstrip(".") return ip 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` """ answers = dns.resolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True) # There should only be one answer here, though in case the server # does actually point to multiple IPs, we just pick the first one answer = cast("SRVRecordAnswer", answers[0]) host = str(answer.target).rstrip(".") port = int(answer.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. """ answers = await dns.asyncresolver.resolve(query_name, RdataType.SRV, lifetime=lifetime, search=True) # There should only be one answer here, though in case the server # does actually point to multiple IPs, we just pick the first one answer = cast("SRVRecordAnswer", answers[0]) host = str(answer.target).rstrip(".") port = int(answer.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) ================================================ FILE: mcstatus/_protocol/__init__.py ================================================ ================================================ FILE: mcstatus/_protocol/bedrock_client.py ================================================ from __future__ import annotations import asyncio import socket import struct from time import perf_counter from typing import TYPE_CHECKING import asyncio_dgram from mcstatus.responses import BedrockStatusResponse if TYPE_CHECKING: from mcstatus._net.address import Address __all__ = ["BedrockClient"] class BedrockClient: request_status_data = 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 ================================================ FILE: mcstatus/_protocol/connection.py ================================================ from __future__ import annotations import asyncio import errno import socket import struct from abc import ABC, abstractmethod from ctypes import c_int32 as signed_int32, c_int64 as signed_int64, c_uint32 as unsigned_int32, c_uint64 as unsigned_int64 from ipaddress import ip_address from typing import TYPE_CHECKING, TypeAlias, cast import asyncio_dgram if TYPE_CHECKING: from collections.abc import Iterable from typing_extensions import Self, SupportsIndex from mcstatus._net.address import Address __all__ = [ "BaseAsyncConnection", "BaseAsyncReadSyncWriteConnection", "BaseConnection", "BaseReadAsync", "BaseReadSync", "BaseSyncConnection", "BaseWriteAsync", "BaseWriteSync", "Connection", "SocketConnection", "TCPAsyncSocketConnection", "TCPSocketConnection", "UDPAsyncSocketConnection", "UDPSocketConnection", ] BytesConvertable: TypeAlias = "SupportsIndex | Iterable[SupportsIndex]" def _ip_type(address: int | str) -> int | None: """Determine the IP version (IPv4 or IPv6). :param address: A string or integer, the IP address. Either IPv4 or IPv6 addresses may be supplied. Integers less than 2**32 will be considered to be IPv4 by default. :return: ``4`` or ``6`` if the IP is IPv4 or IPv6, respectively. :obj:`None` if the IP is invalid. """ try: return ip_address(address).version except ValueError: return None class BaseWriteSync(ABC): """Base synchronous write class.""" __slots__ = () @abstractmethod def write(self, data: Connection | str | bytearray | bytes) -> None: """Write data to ``self``.""" def __repr__(self) -> str: return f"<{self.__class__.__name__} Object>" @staticmethod def _pack(format_: str, data: int) -> bytes: """Pack data in with format in big-endian mode.""" return struct.pack(">" + format_, data) def write_varint(self, value: int) -> None: """Write varint with value ``value`` to ``self``. :param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``. :raises ValueError: If value is out of range. """ remaining = unsigned_int32(value).value for _ in range(5): if not remaining & -0x80: # remaining & ~0x7F == 0: self.write(struct.pack("!B", remaining)) if value > 2**31 - 1 or value < -(2**31): break return self.write(struct.pack("!B", remaining & 0x7F | 0x80)) remaining >>= 7 raise ValueError(f'The value "{value}" is too big to send in a varint') def write_varlong(self, value: int) -> None: """Write varlong with value ``value`` to ``self``. :param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``. :raises ValueError: If value is out of range. """ remaining = unsigned_int64(value).value for _ in range(10): if not remaining & -0x80: # remaining & ~0x7F == 0: self.write(struct.pack("!B", remaining)) if value > 2**63 - 1 or value < -(2**31): break return self.write(struct.pack("!B", remaining & 0x7F | 0x80)) remaining >>= 7 raise ValueError(f'The value "{value}" is too big to send in a varlong') def write_utf(self, value: str) -> None: """Write varint of length of ``value`` up to 32767 bytes, then write ``value`` encoded with ``UTF-8``.""" self.write_varint(len(value)) self.write(bytearray(value, "utf8")) def write_ascii(self, value: str) -> None: """Write value encoded with ``ISO-8859-1``, then write an additional ``0x00`` at the end.""" self.write(bytearray(value, "ISO-8859-1")) self.write(bytearray.fromhex("00")) def write_short(self, value: int) -> None: """Write 2 bytes for value ``-32768 - 32767``.""" self.write(self._pack("h", value)) def write_ushort(self, value: int) -> None: """Write 2 bytes for value ``0 - 65535 (2 ** 16 - 1)``.""" self.write(self._pack("H", value)) def write_int(self, value: int) -> None: """Write 4 bytes for value ``-2147483648 - 2147483647``.""" self.write(self._pack("i", value)) def write_uint(self, value: int) -> None: """Write 4 bytes for value ``0 - 4294967295 (2 ** 32 - 1)``.""" self.write(self._pack("I", value)) def write_long(self, value: int) -> None: """Write 8 bytes for value ``-9223372036854775808 - 9223372036854775807``.""" self.write(self._pack("q", value)) def write_ulong(self, value: int) -> None: """Write 8 bytes for value ``0 - 18446744073709551613 (2 ** 64 - 1)``.""" self.write(self._pack("Q", value)) def write_bool(self, value: bool) -> None: # noqa: FBT001 # Boolean positional argument """Write 1 byte for boolean `True` or `False`.""" self.write(self._pack("?", value)) def write_buffer(self, buffer: Connection) -> None: """Flush buffer, then write a varint of the length of the buffer's data, then write buffer data.""" data = buffer.flush() self.write_varint(len(data)) self.write(data) class BaseWriteAsync(ABC): """Base synchronous write class.""" __slots__ = () @abstractmethod async def write(self, data: Connection | str | bytearray | bytes) -> None: """Write data to ``self``.""" def __repr__(self) -> str: return f"<{self.__class__.__name__} Object>" @staticmethod def _pack(format_: str, data: int) -> bytes: """Pack data in with format in big-endian mode.""" return struct.pack(">" + format_, data) async def write_varint(self, value: int) -> None: """Write varint with value ``value`` to ``self``. :param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``. :raises ValueError: If value is out of range. """ remaining = unsigned_int32(value).value for _ in range(5): if not remaining & -0x80: # remaining & ~0x7F == 0: await self.write(struct.pack("!B", remaining)) if value > 2**31 - 1 or value < -(2**31): break return await self.write(struct.pack("!B", remaining & 0x7F | 0x80)) remaining >>= 7 raise ValueError(f'The value "{value}" is too big to send in a varint') async def write_varlong(self, value: int) -> None: """Write varlong with value ``value`` to ``self``. :param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``. :raises ValueError: If value is out of range. """ remaining = unsigned_int64(value).value for _ in range(10): if not remaining & -0x80: # remaining & ~0x7F == 0: await self.write(struct.pack("!B", remaining)) if value > 2**63 - 1 or value < -(2**31): break return await self.write(struct.pack("!B", remaining & 0x7F | 0x80)) remaining >>= 7 raise ValueError(f'The value "{value}" is too big to send in a varlong') async def write_utf(self, value: str) -> None: """Write varint of length of ``value`` up to 32767 bytes, then write ``value`` encoded with ``UTF-8``.""" await self.write_varint(len(value)) await self.write(bytearray(value, "utf8")) async def write_ascii(self, value: str) -> None: """Write value encoded with ``ISO-8859-1``, then write an additional ``0x00`` at the end.""" await self.write(bytearray(value, "ISO-8859-1")) await self.write(bytearray.fromhex("00")) async def write_short(self, value: int) -> None: """Write 2 bytes for value ``-32768 - 32767``.""" await self.write(self._pack("h", value)) async def write_ushort(self, value: int) -> None: """Write 2 bytes for value ``0 - 65535 (2 ** 16 - 1)``.""" await self.write(self._pack("H", value)) async def write_int(self, value: int) -> None: """Write 4 bytes for value ``-2147483648 - 2147483647``.""" await self.write(self._pack("i", value)) async def write_uint(self, value: int) -> None: """Write 4 bytes for value ``0 - 4294967295 (2 ** 32 - 1)``.""" await self.write(self._pack("I", value)) async def write_long(self, value: int) -> None: """Write 8 bytes for value ``-9223372036854775808 - 9223372036854775807``.""" await self.write(self._pack("q", value)) async def write_ulong(self, value: int) -> None: """Write 8 bytes for value ``0 - 18446744073709551613 (2 ** 64 - 1)``.""" await self.write(self._pack("Q", value)) async def write_bool(self, value: bool) -> None: # noqa: FBT001 # Boolean positional argument """Write 1 byte for boolean `True` or `False`.""" await self.write(self._pack("?", value)) async def write_buffer(self, buffer: Connection) -> None: """Flush buffer, then write a varint of the length of the buffer's data, then write buffer data.""" data = buffer.flush() await self.write_varint(len(data)) await self.write(data) class BaseReadSync(ABC): """Base synchronous read class.""" __slots__ = () @abstractmethod def read(self, length: int, /) -> bytearray: """Read length bytes from ``self``, and return a byte array.""" def __repr__(self) -> str: return f"<{self.__class__.__name__} Object>" @staticmethod def _unpack(format_: str, data: bytes) -> int: """Unpack data as bytes with format in big-endian.""" return struct.unpack(">" + format_, bytes(data))[0] def read_varint(self) -> int: """Read varint from ``self`` and return it. :param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``. :raises IOError: If varint received is out of range. """ result = 0 for i in range(5): part = self.read(1)[0] result |= (part & 0x7F) << (7 * i) if not part & 0x80: return signed_int32(result).value raise OSError("Received varint is too big!") def read_varlong(self) -> int: """Read varlong from ``self`` and return it. :param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``. :raises IOError: If varint received is out of range. """ result = 0 for i in range(10): part = self.read(1)[0] result |= (part & 0x7F) << (7 * i) if not part & 0x80: return signed_int64(result).value raise OSError("Received varlong is too big!") def read_utf(self) -> str: """Read up to 32767 bytes by reading a varint, then decode bytes as ``UTF-8``.""" length = self.read_varint() return self.read(length).decode("utf8") def read_ascii(self) -> str: """Read ``self`` until last value is not zero, then return that decoded with ``ISO-8859-1``.""" result = bytearray() while len(result) == 0 or result[-1] != 0: result.extend(self.read(1)) return result[:-1].decode("ISO-8859-1") def read_short(self) -> int: """Return ``-32768 - 32767``. Read 2 bytes.""" return self._unpack("h", self.read(2)) def read_ushort(self) -> int: """Return ``0 - 65535 (2 ** 16 - 1)``. Read 2 bytes.""" return self._unpack("H", self.read(2)) def read_int(self) -> int: """Return ``-2147483648 - 2147483647``. Read 4 bytes.""" return self._unpack("i", self.read(4)) def read_uint(self) -> int: """Return ``0 - 4294967295 (2 ** 32 - 1)``. 4 bytes read.""" return self._unpack("I", self.read(4)) def read_long(self) -> int: """Return ``-9223372036854775808 - 9223372036854775807``. Read 8 bytes.""" return self._unpack("q", self.read(8)) def read_ulong(self) -> int: """Return ``0 - 18446744073709551613 (2 ** 64 - 1)``. Read 8 bytes.""" return self._unpack("Q", self.read(8)) def read_bool(self) -> bool: """Return `True` or `False`. Read 1 byte.""" return cast("bool", self._unpack("?", self.read(1))) def read_buffer(self) -> Connection: """Read a varint for length, then return a new connection from length read bytes.""" length = self.read_varint() result = Connection() result.receive(self.read(length)) return result class BaseReadAsync(ABC): """Asynchronous Read connection base class.""" __slots__ = () @abstractmethod async def read(self, length: int, /) -> bytearray: """Read length bytes from ``self``, return a byte array.""" def __repr__(self) -> str: return f"<{self.__class__.__name__} Object>" @staticmethod def _unpack(format_: str, data: bytes) -> int: """Unpack data as bytes with format in big-endian.""" return struct.unpack(">" + format_, bytes(data))[0] async def read_varint(self) -> int: """Read varint from ``self`` and return it. :param value: Maximum is ``2 ** 31 - 1``, minimum is ``-(2 ** 31)``. :raises IOError: If varint received is out of range. """ result = 0 for i in range(5): part = (await self.read(1))[0] result |= (part & 0x7F) << 7 * i if not part & 0x80: return signed_int32(result).value raise OSError("Received a varint that was too big!") async def read_varlong(self) -> int: """Read varlong from ``self`` and return it. :param value: Maximum is ``2 ** 63 - 1``, minimum is ``-(2 ** 63)``. :raises IOError: If varint received is out of range. """ result = 0 for i in range(10): part = (await self.read(1))[0] result |= (part & 0x7F) << (7 * i) if not part & 0x80: return signed_int64(result).value raise OSError("Received varlong is too big!") async def read_utf(self) -> str: """Read up to 32767 bytes by reading a varint, then decode bytes as ``UTF-8``.""" length = await self.read_varint() return (await self.read(length)).decode("utf8") async def read_ascii(self) -> str: """Read ``self`` until last value is not zero, then return that decoded with ``ISO-8859-1``.""" result = bytearray() while len(result) == 0 or result[-1] != 0: result.extend(await self.read(1)) return result[:-1].decode("ISO-8859-1") async def read_short(self) -> int: """Return ``-32768 - 32767``. Read 2 bytes.""" return self._unpack("h", await self.read(2)) async def read_ushort(self) -> int: """Return ``0 - 65535 (2 ** 16 - 1)``. Read 2 bytes.""" return self._unpack("H", await self.read(2)) async def read_int(self) -> int: """Return ``-2147483648 - 2147483647``. Read 4 bytes.""" return self._unpack("i", await self.read(4)) async def read_uint(self) -> int: """Return ``0 - 4294967295 (2 ** 32 - 1)``. 4 bytes read.""" return self._unpack("I", await self.read(4)) async def read_long(self) -> int: """Return ``-9223372036854775808 - 9223372036854775807``. Read 8 bytes.""" return self._unpack("q", await self.read(8)) async def read_ulong(self) -> int: """Return ``0 - 18446744073709551613 (2 ** 64 - 1)``. Read 8 bytes.""" return self._unpack("Q", await self.read(8)) async def read_bool(self) -> bool: """Return `True` or `False`. Read 1 byte.""" return cast("bool", self._unpack("?", await self.read(1))) async def read_buffer(self) -> Connection: """Read a varint for length, then return a new connection from length read bytes.""" length = await self.read_varint() result = Connection() result.receive(await self.read(length)) return result class BaseConnection: """Base Connection class. Implements flush, receive, and remaining.""" __slots__ = () def __repr__(self) -> str: return f"<{self.__class__.__name__} Object>" def flush(self) -> bytearray: """Raise :exc:`TypeError`, unsupported.""" raise TypeError(f"{self.__class__.__name__} does not support flush()") def receive(self, _data: BytesConvertable | bytearray) -> None: """Raise :exc:`TypeError`, unsupported.""" raise TypeError(f"{self.__class__.__name__} does not support receive()") def remaining(self) -> int: """Raise :exc:`TypeError`, unsupported.""" raise TypeError(f"{self.__class__.__name__} does not support remaining()") class BaseSyncConnection(BaseConnection, BaseReadSync, BaseWriteSync): """Base synchronous read and write class.""" __slots__ = () class BaseAsyncReadSyncWriteConnection(BaseConnection, BaseReadAsync, BaseWriteSync): """Base asynchronous read and synchronous write class.""" __slots__ = () class BaseAsyncConnection(BaseConnection, BaseReadAsync, BaseWriteAsync): """Base asynchronous read and write class.""" __slots__ = () class Connection(BaseSyncConnection): """Base connection class.""" __slots__ = ("received", "sent") def __init__(self) -> None: self.sent = bytearray() self.received = bytearray() def read(self, length: int, /) -> bytearray: """Return :attr:`.received` up to length bytes, then cut received up to that point.""" if len(self.received) < length: raise OSError(f"Not enough data to read! {len(self.received)} < {length}") result = self.received[:length] self.received = self.received[length:] return result def write(self, data: Connection | str | bytearray | bytes) -> None: """Extend :attr:`.sent` from ``data``.""" if isinstance(data, Connection): data = data.flush() if isinstance(data, str): data = bytearray(data, "utf-8") self.sent.extend(data) def receive(self, data: BytesConvertable | bytearray) -> None: """Extend :attr:`.received` with ``data``.""" if not isinstance(data, bytearray): data = bytearray(data) self.received.extend(data) def remaining(self) -> int: """Return length of :attr:`.received`.""" return len(self.received) def flush(self) -> bytearray: """Return :attr:`.sent`, also clears :attr:`.sent`.""" result, self.sent = self.sent, bytearray() return result def copy(self) -> Connection: """Return a copy of ``self``.""" new = self.__class__() new.receive(self.received) new.write(self.sent) return new class SocketConnection(BaseSyncConnection): """Socket connection.""" __slots__ = ("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 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() 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) def read(self, length: int, /) -> bytearray: """Return length bytes read from :attr:`.socket`. Raises :exc:`IOError` 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 result def write(self, data: Connection | str | bytes | bytearray) -> None: """Send data on :attr:`.socket`.""" if isinstance(data, Connection): data = bytearray(data.flush()) elif isinstance(data, str): data = bytearray(data, "utf-8") self.socket.send(data) 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_type(addr[0]) == 4 else socket.AF_INET6, socket.SOCK_DGRAM, ) self.socket.settimeout(timeout) def remaining(self) -> int: """Always return ``65535`` (``2 ** 16 - 1``).""" # noqa: D401 # imperative mood return 65535 def read(self, _length: int, /) -> bytearray: """Return up to :meth:`.remaining` bytes. Length does nothing here.""" result = bytearray() while len(result) == 0: result.extend(self.socket.recvfrom(self.remaining())[0]) return result def write(self, data: Connection | str | bytes | bytearray) -> None: """Use :attr:`.socket` to send data to :attr:`.addr`.""" if isinstance(data, Connection): data = bytearray(data.flush()) elif isinstance(data, str): data = bytearray(data, "utf-8") self.socket.sendto(data, self.addr) class TCPAsyncSocketConnection(BaseAsyncReadSyncWriteConnection): """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 sock: socket.socket = self.writer.transport.get_extra_info("socket") sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) async def read(self, length: int, /) -> bytearray: """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: raise OSError("Socket did not respond with any information!") result.extend(new) return result def write(self, data: Connection | str | bytes | bytearray) -> None: """Write data to :attr:`.writer`.""" if isinstance(data, Connection): data = bytearray(data.flush()) elif isinstance(data, str): data = bytearray(data, "utf-8") self.writer.write(data) def close(self) -> None: """Close :attr:`.writer`.""" if self.writer is not None: # If initialized self.writer.close() async def __aenter__(self) -> Self: await self.connect() return self async def __aexit__(self, *_: object) -> None: self.close() 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) def remaining(self) -> int: """Always return ``65535`` (``2 ** 16 - 1``).""" # noqa: D401 # imperative mood return 65535 async def read(self, _length: int, /) -> bytearray: """Read from :attr:`.stream`. Length does nothing here.""" data, _remote_addr = await asyncio.wait_for(self.stream.recv(), timeout=self.timeout) return bytearray(data) async def write(self, data: Connection | str | bytes | bytearray) -> None: """Send data with :attr:`.stream`.""" if isinstance(data, Connection): data = bytearray(data.flush()) elif isinstance(data, str): data = bytearray(data, "utf-8") await self.stream.send(data) def close(self) -> None: """Close :attr:`.stream`.""" if self.stream is not None: # If initialized self.stream.close() async def __aenter__(self) -> Self: await self.connect() return self async def __aexit__(self, *_: object) -> None: self.close() ================================================ FILE: mcstatus/_protocol/java_client.py ================================================ from __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.connection import Connection, TCPAsyncSocketConnection, TCPSocketConnection from mcstatus.responses import JavaStatusResponse if TYPE_CHECKING: from collections.abc import Awaitable from mcstatus._net.address import Address from mcstatus.responses._raw import RawJavaResponse __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: self.ping_token = random.randint(0, (1 << 63) - 1) def handshake(self) -> None: """Write the initial handshake packet to the connection.""" packet = Connection() packet.write_varint(0) packet.write_varint(self.version) packet.write_utf(self.address.host) packet.write_ushort(self.address.port) packet.write_varint(1) # Intention to query status self.connection.write_buffer(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: Connection, 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: Connection, 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_long() 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 read_status(self) -> JavaStatusResponse: """Send the status request and read the response.""" request = Connection() request.write_varint(0) # Request status self.connection.write_buffer(request) start = perf_counter() response = self.connection.read_buffer() end = perf_counter() return self._handle_status_response(response, start, end) def test_ping(self) -> float: """Send a ping token and measure the latency.""" request = Connection() request.write_varint(1) # Test ping request.write_long(self.ping_token) start = perf_counter() self.connection.write_buffer(request) response = self.connection.read_buffer() end = perf_counter() return self._handle_ping_response(response, start, end) @final @dataclass class AsyncJavaClient(_BaseJavaClient): connection: TCPAsyncSocketConnection # pyright: ignore[reportIncompatibleVariableOverride] async def read_status(self) -> JavaStatusResponse: """Send the status request and read the response.""" request = Connection() request.write_varint(0) # Request status self.connection.write_buffer(request) start = perf_counter() response = await self.connection.read_buffer() end = perf_counter() return self._handle_status_response(response, start, end) async def test_ping(self) -> float: """Send a ping token and measure the latency.""" request = Connection() request.write_varint(1) # Test ping request.write_long(self.ping_token) start = perf_counter() self.connection.write_buffer(request) response = await self.connection.read_buffer() end = perf_counter() return self._handle_ping_response(response, start, end) ================================================ FILE: mcstatus/_protocol/legacy_client.py ================================================ from time import perf_counter from mcstatus._protocol.connection import BaseAsyncReadSyncWriteConnection, BaseSyncConnection from mcstatus.responses import LegacyStatusResponse __all__ = ["AsyncLegacyClient", "LegacyClient"] class _BaseLegacyClient: request_status_data = 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) 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_ushort() data = self.connection.read(length * 2) end = perf_counter() return self.parse_response(data, (end - start) * 1000) class AsyncLegacyClient(_BaseLegacyClient): def __init__(self, connection: BaseAsyncReadSyncWriteConnection) -> None: self.connection = connection async def read_status(self) -> LegacyStatusResponse: """Send the status request and read the response.""" start = perf_counter() 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_ushort() data = await self.connection.read(length * 2) end = perf_counter() return self.parse_response(data, (end - start) * 1000) ================================================ FILE: mcstatus/_protocol/query_client.py ================================================ from __future__ import annotations import random import re import struct from abc import abstractmethod from dataclasses import dataclass, field from typing import ClassVar, TYPE_CHECKING, final from mcstatus._protocol.connection import Connection, UDPAsyncSocketConnection, UDPSocketConnection from mcstatus.responses import QueryResponse from mcstatus.responses._raw import RawQueryResponse __all__ = ["AsyncQueryClient", "QueryClient"] if TYPE_CHECKING: from collections.abc import Awaitable @dataclass class _BaseQueryClient: MAGIC_PREFIX: ClassVar = bytearray.fromhex("FEFD") PADDING: ClassVar = bytearray.fromhex("00000000") PACKET_TYPE_CHALLENGE: ClassVar = 9 PACKET_TYPE_QUERY: ClassVar = 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) -> Connection: packet = Connection() packet.write(self.MAGIC_PREFIX) packet.write(struct.pack("!B", self.PACKET_TYPE_QUERY)) packet.write_uint(self._generate_session_id()) packet.write_int(self.challenge) packet.write(self.PADDING) return packet def _create_handshake_packet(self) -> Connection: packet = Connection() packet.write(self.MAGIC_PREFIX) packet.write(struct.pack("!B", self.PACKET_TYPE_CHALLENGE)) packet.write_uint(self._generate_session_id()) return packet @abstractmethod def _read_packet(self) -> Connection | Awaitable[Connection]: raise NotImplementedError @abstractmethod def handshake(self) -> None | Awaitable[None]: raise NotImplementedError @abstractmethod def read_query(self) -> QueryResponse | Awaitable[QueryResponse]: raise NotImplementedError def _parse_response(self, response: Connection) -> 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 = {} while True: key = response.read_ascii() if key == "hostname": # hostname is actually motd in the query protocol match = re.search( b"(.*?)\x00(hostip|hostport|game_id|gametype|map|maxplayers|numplayers|plugins|version)", response.received, 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 = [] 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] def _read_packet(self) -> Connection: packet = Connection() packet.receive(self.connection.read(self.connection.remaining())) packet.read(1 + 4) return packet def handshake(self) -> None: self.connection.write(self._create_handshake_packet()) packet = self._read_packet() self.challenge = int(packet.read_ascii()) 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] async def _read_packet(self) -> Connection: packet = Connection() packet.receive(await self.connection.read(self.connection.remaining())) packet.read(1 + 4) return packet async def handshake(self) -> None: await self.connection.write(self._create_handshake_packet()) packet = await self._read_packet() self.challenge = int(packet.read_ascii()) 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)) ================================================ FILE: mcstatus/_utils/__init__.py ================================================ from 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"] ================================================ FILE: mcstatus/_utils/deprecation.py ================================================ from __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


================================================
FILE: mcstatus/_utils/general.py
================================================
from 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


================================================
FILE: mcstatus/_utils/retry.py
================================================
from __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.
    """  # noqa: D401 # 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] # We know func is awaitable here
                except exceptions as exc:  # noqa: PERF203 # try-except within a 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:  # noqa: PERF203 # try-except within a 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


================================================
FILE: mcstatus/motd/__init__.py
================================================
from __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 Formatting, MinecraftColor, 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-FK-OR])", 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 = raw.copy() if hasattr(raw, "copy") else raw  # pyright: ignore[reportAttributeAccessIssue] # Cannot access "copy" for type "str"
        if isinstance(raw, list):
            raw: RawJavaResponseMotdWhenDict = {"extra": raw}

        if isinstance(raw, str):
            parsed = cls._parse_as_str(raw, bedrock=bedrock)
        elif isinstance(raw, dict):
            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 returns a lot of empty strings, use :meth:`Motd.simplify` to remove them.

        :param raw: Raw MOTD, directly from server.
        :param bedrock: Is server Bedrock Edition?
            Ignores :attr:`MinecraftColor.MINECOIN_GOLD` if it's :obj:`False`.
        :returns: :obj:`ParsedMotdComponent` list, which need to be passed to ``__init__``.
        """
        parsed_motd: list[ParsedMotdComponent] = []

        split_raw = _MOTD_COLORS_RE.split(raw)
        for element in split_raw:
            clean_element = element.lstrip("&§").lower()
            standardized_element = element.replace("&", "§").lower()

            if standardized_element == "§g" and not bedrock:
                parsed_motd.append(element)  # minecoin_gold on java server, treat as string
                continue

            if standardized_element.startswith("§"):
                try:
                    parsed_motd.append(MinecraftColor(clean_element))
                except ValueError:
                    try:
                        parsed_motd.append(Formatting(clean_element))
                    except ValueError:
                        # just a text
                        parsed_motd.append(element)
            else:
                parsed_motd.append(element)

        return parsed_motd

    @classmethod
    def _parse_as_dict(
        cls,
        item: RawJavaResponseMotdWhenDict,
        *,
        bedrock: bool = False,
        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__``.
        """
        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))

        for style_key, style_val in Formatting.__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.RESET)

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

            for element in item["extra"]:
                parsed_motd.extend(
                    cls._parse_as_dict(element, 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) -> ParsedMotdComponent:
        """Parse a color string."""
        try:
            return MinecraftColor[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.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().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().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

""" # noqa: D301 # 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) ================================================ FILE: mcstatus/motd/_simplifies.py ================================================ from __future__ import annotations import typing as t from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, WebColor if t.TYPE_CHECKING: from collections.abc import Sequence __all__ = [ "get_double_colors", "get_double_items", "get_empty_text", "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_empty_text, 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] = 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, (Formatting, MinecraftColor, 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, (MinecraftColor, 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 = [] for index, item in enumerate(parsed): # Collect the indices of formatting items if isinstance(item, Formatting): 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 formattings, the formattings 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 formattings, these formattings will be overridden # as colors reset everything. This makes these formattings pointless, mark them # for removal. if isinstance(item, (MinecraftColor, WebColor)): to_remove.update(collected_formattings) collected_formattings = [] return to_remove def get_empty_text(parsed: Sequence[ParsedMotdComponent]) -> set[int]: """Get indices of all empty text items. Empty strings in motd serve no purpose and can be marked for removal. """ to_remove: set[int] = set() for index, item in enumerate(parsed): if isinstance(item, str) and len(item) == 0: to_remove.add(index) 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, (MinecraftColor, WebColor, Formatting)): 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: MinecraftColor | WebColor | None = None active_formatting: Formatting | None = None for index, item in enumerate(parsed): if isinstance(item, (MinecraftColor, WebColor)): if active_color == item: to_remove.add(index) active_color = item continue if isinstance(item, Formatting): if item == Formatting.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 ================================================ FILE: mcstatus/motd/_transformers.py ================================================ from __future__ import annotations import abc import typing as t from collections.abc import Callable, Sequence from mcstatus.motd.components import Formatting, MinecraftColor, ParsedMotdComponent, TranslationTag, WebColor __all__ = [ "AnsiTransformer", "HtmlTransformer", "MinecraftTransformer", "PlainTransformer", ] if t.TYPE_CHECKING: from collections.abc import Callable, Sequence _HOOK_RETURN_TYPE = t.TypeVar("_HOOK_RETURN_TYPE") _END_RESULT_TYPE = t.TypeVar("_END_RESULT_TYPE") # MinecraftColor: (foreground, background) # noqa: ERA001 # commented-out code _SHARED_MINECRAFT_COLOR_TO_RGB = { MinecraftColor.BLACK: ((0, 0, 0), (0, 0, 0)), MinecraftColor.DARK_BLUE: ((0, 0, 170), (0, 0, 42)), MinecraftColor.DARK_GREEN: ((0, 170, 0), (0, 42, 0)), MinecraftColor.DARK_AQUA: ((0, 170, 170), (0, 42, 42)), MinecraftColor.DARK_RED: ((170, 0, 0), (42, 0, 0)), MinecraftColor.DARK_PURPLE: ((170, 0, 170), (42, 0, 42)), MinecraftColor.GOLD: ((255, 170, 0), (64, 42, 0)), MinecraftColor.GRAY: ((170, 170, 170), (42, 42, 42)), MinecraftColor.DARK_GRAY: ((85, 85, 85), (21, 21, 21)), MinecraftColor.BLUE: ((85, 85, 255), (21, 21, 63)), MinecraftColor.GREEN: ((85, 255, 85), (21, 63, 21)), MinecraftColor.AQUA: ((85, 255, 255), (21, 63, 63)), MinecraftColor.RED: ((255, 85, 85), (63, 21, 21)), MinecraftColor.LIGHT_PURPLE: ((255, 85, 255), (63, 21, 63)), MinecraftColor.YELLOW: ((255, 255, 85), (63, 63, 21)), MinecraftColor.WHITE: ((255, 255, 255), (63, 63, 63)), } _MINECRAFT_COLOR_TO_RGB_JAVA = _SHARED_MINECRAFT_COLOR_TO_RGB.copy() _MINECRAFT_COLOR_TO_RGB_JAVA[MinecraftColor.GRAY] = ((170, 170, 170), (42, 42, 42)) _MINECRAFT_COLOR_TO_RGB_BEDROCK = _SHARED_MINECRAFT_COLOR_TO_RGB.copy() _MINECRAFT_COLOR_TO_RGB_BEDROCK.update( { MinecraftColor.GRAY: ((198, 198, 198), (49, 49, 49)), MinecraftColor.MINECOIN_GOLD: ((221, 214, 5), (55, 53, 1)), MinecraftColor.MATERIAL_QUARTZ: ((227, 212, 209), (56, 53, 52)), MinecraftColor.MATERIAL_IRON: ((206, 202, 202), (51, 50, 50)), MinecraftColor.MATERIAL_NETHERITE: ((68, 58, 59), (17, 14, 14)), MinecraftColor.MATERIAL_REDSTONE: ((151, 22, 7), (37, 5, 1)), MinecraftColor.MATERIAL_COPPER: ((180, 104, 77), (45, 26, 19)), MinecraftColor.MATERIAL_GOLD: ((222, 177, 45), (55, 44, 11)), MinecraftColor.MATERIAL_EMERALD: ((17, 159, 54), (4, 40, 13)), MinecraftColor.MATERIAL_DIAMOND: ((44, 186, 168), (11, 46, 42)), MinecraftColor.MATERIAL_LAPIS: ((33, 73, 123), (8, 18, 30)), MinecraftColor.MATERIAL_AMETHYST: ((154, 92, 198), (38, 23, 49)), MinecraftColor.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 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]: handler: Callable[[ParsedMotdComponent], _HOOK_RETURN_TYPE] = { MinecraftColor: self._handle_minecraft_color, WebColor: self._handle_web_color, Formatting: self._handle_formatting, TranslationTag: self._handle_translation_tag, str: self._handle_str, }[type(component)] additional = None if isinstance(component, MinecraftColor): additional = self._handle_formatting(Formatting.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: Formatting, /) -> _HOOK_RETURN_TYPE: ... @abc.abstractmethod def _handle_minecraft_color(self, element: MinecraftColor, /) -> _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. """ def _format_output(self, results: list[str]) -> str: return "".join(results) def _handle_str(self, _element: str, /) -> str: return "" def _handle_minecraft_color(self, _element: MinecraftColor, /) -> str: return "" def _handle_web_color(self, _element: WebColor, /) -> str: return "" def _handle_formatting(self, _element: Formatting, /) -> str: return "" def _handle_translation_tag(self, _element: TranslationTag, /) -> str: return "" class PlainTransformer(_NothingTransformer): def _handle_str(self, element: str, /) -> str: return element class MinecraftTransformer(PlainTransformer): 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 def _handle_minecraft_color(self, element: MinecraftColor, /) -> str: return "§" + element.value def _handle_formatting(self, element: Formatting, /) -> str: return "§" + element.value class HtmlTransformer(PlainTransformer): _FORMATTING_TO_HTML_TAGS: t.ClassVar = { Formatting.BOLD: "b", Formatting.STRIKETHROUGH: "s", Formatting.ITALIC: "i", Formatting.UNDERLINED: "u", } # TODO: When dropping v13 support, make sure to drop the default value for the bedrock arg def __init__(self, *, bedrock: bool = False) -> None: self.bedrock = bedrock self.on_reset: list[str] = [] def transform(self, motd_components: Sequence[ParsedMotdComponent]) -> str: self.on_reset = [] return super().transform(motd_components) def _format_output(self, results: list[str]) -> str: return "

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

" def _handle_str(self, element: str, /) -> str: return element.replace("\n", "
") def _handle_minecraft_color(self, element: MinecraftColor, /) -> str: color_map = _MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else _MINECRAFT_COLOR_TO_RGB_JAVA fg_color, bg_color = color_map[element] self.on_reset.append("") return f"" def _handle_web_color(self, element: WebColor, /) -> str: self.on_reset.append("") return f"" def _handle_formatting(self, element: Formatting, /) -> str: if element is Formatting.RESET: to_return = "".join(self.on_reset) self.on_reset = [] return to_return if element is Formatting.OBFUSCATED: self.on_reset.append("") return "" tag_name = self._FORMATTING_TO_HTML_TAGS[element] self.on_reset.append(f"") return f"<{tag_name}>" class AnsiTransformer(PlainTransformer): _FORMATTING_TO_ANSI_TAGS: t.ClassVar = { Formatting.BOLD: "1", Formatting.STRIKETHROUGH: "9", Formatting.ITALIC: "3", Formatting.UNDERLINED: "4", Formatting.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() } # TODO: When dropping v13 support, make sure to drop the default value for the bedrock arg def __init__(self, *, bedrock: bool = True) -> None: self.bedrock = bedrock def ansi_color(self, color: tuple[int, int, int] | MinecraftColor) -> str: """Transform RGB color to ANSI color code.""" if isinstance(color, MinecraftColor): color_to_rgb = self._MINECRAFT_COLOR_TO_RGB_BEDROCK if self.bedrock else self._MINECRAFT_COLOR_TO_RGB_JAVA color = color_to_rgb[color] return "\033[38;2;{};{};{}m".format(*color) def _format_output(self, results: list[str]) -> str: return "\033[0m" + super()._format_output(results) + "\033[0m" def _handle_minecraft_color(self, element: MinecraftColor, /) -> str: return self.ansi_color(element) def _handle_web_color(self, element: WebColor, /) -> str: return self.ansi_color(element.rgb) def _handle_formatting(self, element: Formatting, /) -> str: if element is Formatting.RESET: return "\033[0m" return "\033[" + self._FORMATTING_TO_ANSI_TAGS[element] + "m" ================================================ FILE: mcstatus/motd/components.py ================================================ from __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__ = [ "Formatting", "MinecraftColor", "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 = "Formatting | MinecraftColor | WebColor | TranslationTag | str" class Formatting(Enum): """Enum for Formatting codes. See `Minecraft wiki `__ for more info. .. note:: :attr:`.STRIKETHROUGH` and :attr:`.UNDERLINED` don't work on Bedrock, which our parser doesn't keep it in mind. See `MCPE-41729 `_. """ BOLD = "l" ITALIC = "o" UNDERLINED = "n" STRIKETHROUGH = "m" OBFUSCATED = "k" RESET = "r" class MinecraftColor(Enum): """Enum for 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" # Only for bedrock 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" @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: # noqa: A002 # shadowing a hex builtin """Construct web color using hex color string. :raises ValueError: Invalid hex color string. :returns: New :class:`WebColor` instance. """ hex = hex.lstrip("#") # noqa: A001 # shadowing a hex builtin 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) # noqa: A001 # shadowing a hex builtin 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) # noqa: A001 # shadowing a hex builtin 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 ================================================ FILE: mcstatus/py.typed ================================================ ================================================ FILE: mcstatus/responses/__init__.py ================================================ from 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", ] ================================================ FILE: mcstatus/responses/_raw.py ================================================ from __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] 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 ================================================ FILE: mcstatus/responses/base.py ================================================ from __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 `__. """ ================================================ FILE: mcstatus/responses/bedrock.py ================================================ from __future__ import annotations from dataclasses import dataclass from typing import Any, TYPE_CHECKING from mcstatus._utils import deprecated from mcstatus.motd import Motd from mcstatus.responses.base import BaseStatusPlayers, BaseStatusResponse, BaseStatusVersion if TYPE_CHECKING: from typing_extensions import Self __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.""" @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.""" @property @deprecated(replacement="name", removal_version="13.0.0") def version(self) -> str: """ .. deprecated:: 12.0.0 Will be removed in 13.0.0, use :attr:`.name` instead. """ # noqa: D205, D212 # no summary line return self.name ================================================ FILE: mcstatus/responses/forge.py ================================================ """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 from mcstatus._protocol.connection import BaseConnection, BaseReadSync, Connection from mcstatus._utils import or_none if TYPE_CHECKING: from typing_extensions import Self from mcstatus.responses._raw import RawForgeData, RawForgeDataChannel, RawForgeDataMod __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: Connection, mod_id: str | None = None) -> Self: """Decode an object about Forge channel from decoded optimized buffer. :param buffer: :class:`Connection` 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_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: Connection) -> tuple[Self, list[ForgeDataChannel]]: """Decode data about a Forge mod from decoded optimized buffer. :param buffer: :class:`Connection` 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 class _StringBuffer(BaseReadSync, BaseConnection): """String Buffer for reading utf-16 encoded binary data.""" __slots__ = ("received", "stringio") def __init__(self, stringio: StringIO) -> None: self.stringio = stringio self.received = bytearray() def read(self, length: int) -> bytearray: """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 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_short() | (self.read_short() << 15) def read_optimized_buffer(self) -> Connection: """Read encoded buffer.""" size = self.read_optimized_size() buffer = Connection() value, bits = 0, 0 while buffer.remaining() < size: if bits < 8 and self.remaining(): # Ignoring sign bit value |= (self.read_short() & 0x7FFF) << bits bits += 15 buffer.receive((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) -> Connection: """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 # noqa: E501 # line too long 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_bool() mod_count = buffer.read_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, ) ================================================ FILE: mcstatus/responses/java.py ================================================ from __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 from mcstatus.responses._raw import RawJavaResponse, RawJavaResponsePlayer, RawJavaResponsePlayers, RawJavaResponseVersion __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.""" @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")) and raw_forge is not None: 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, ) @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"]) ================================================ FILE: mcstatus/responses/legacy.py ================================================ from __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 __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).""" @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. """ ================================================ FILE: mcstatus/responses/query.py ================================================ from __future__ import annotations from dataclasses import asdict, dataclass from typing import Any, TYPE_CHECKING from mcstatus._utils import deprecated 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 @property @deprecated(replacement="map_name", removal_version="13.0.0") def map(self) -> str | None: """ .. deprecated:: 12.0.0 Will be removed in 13.0.0, use :attr:`.map_name` instead. """ # noqa: D205, D212 # no summary line return self.map_name @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, ) @property @deprecated(replacement="'list' attribute", removal_version="13.0.0") def names(self) -> list[str]: """ .. deprecated:: 12.0.0 Will be removed in 13.0.0, use :attr:`.list` instead. """ # noqa: D205, D212 # no summary line return self.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 ================================================ FILE: mcstatus/server.py ================================================ from __future__ import annotations from abc import ABC from typing import TYPE_CHECKING 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.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 from mcstatus.responses import BedrockStatusResponse, JavaStatusResponse, LegacyStatusResponse, QueryResponse __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: 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. """ # noqa: D205, D212 # no summary line if port is None: port = self.DEFAULT_PORT self.address = Address(host, port) self.timeout = 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 = 25565 @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) 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. """ # noqa: D205, D212 # no summary 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, # noqa: ARG002 # unused 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, # noqa: ARG002 # unused 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 ) 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, # noqa: ARG002 # unused 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, # noqa: ARG002 # unused 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 ) 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: # noqa: ARG002 # unused 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: # noqa: ARG002 # unused argument async with UDPAsyncSocketConnection(address, self.timeout) as connection: query_client = AsyncQueryClient(connection) await query_client.handshake() return await query_client.read_query() 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: # noqa: ARG002 # unused 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: # noqa: ARG002 # unused 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() class BedrockServer(MCServer): """Base class for a Minecraft Bedrock Edition server.""" DEFAULT_PORT = 19132 @retry(tries=3) def status(self, *, tries: int = 3) -> BedrockStatusResponse: # noqa: ARG002 # unused 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: # noqa: ARG002 # unused 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() ================================================ FILE: pyproject.toml ================================================ [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.44.0"] lint = [ "pre-commit~=4.5.1", "ruff~=0.15.0", "pyright==1.1.408", "typing-extensions~=4.15.0", ] test = [ "pytest~=9.0.2", "pytest-asyncio~=1.3.0", "pytest-cov~=7.1.0", "pytest-rerunfailures~=16.1", "coverage~=7.13.1", "typing-extensions~=4.15.0", # the actual versions are in the `release` group "uv-dynamic-versioning", "hatchling", ] docs = ["docs; python_version >= '3.12'"] release = ["hatchling~=1.29.0", "uv-dynamic-versioning~=0.14.0"] [tool.poe.tasks] _lint_ruff = "ruff check ." _lint_pyright = "pyright ." [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" tmp_path_retention_policy = "failed" addopts = "--strict-markers --doctest-modules --cov=mcstatus --cov-append --cov-branch --cov-report=term-missing -vvv --asyncio-mode=strict" testpaths = ["tests"] # Remove deprecation warning asyncio_default_fixture_loop_scope = "function" [tool.pyright] pythonPlatform = "All" pythonVersion = "3.10" typeCheckingMode = "standard" disableBytesTypePromotions = false enableTypeIgnoreComments = false reportUnnecessaryTypeIgnoreComment = true [tool.ruff] target-version = "py310" line-length = 127 [tool.ruff.lint] select = ["ALL"] ignore = [ "EM", # Very weird rules for using exceptions "FIX", # Line contains TODO, consider resolving the issue "COM812", # Missing trailing comma (in multiline lists/tuples/...) "D203", # Blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "PLR2004", # Magic value used in comparison "RET504", # Unnecessary assignment to a variable before `return` statement "S101", # Use of `assert` detected "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes "TD002", # Missing author in TODO "TD003", # Missing issue link for this TODO "TRY003", # Avoid specifying long messages outside the exception class "ISC002", # Multi line implicit string concatenation # Ruff often identifies private modules as public, because we don't prefix them with `_` "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method "D103", # Missing docstring in public function "D104", # Missing docstring in public package "D105", # Missing docstring in magic method "D106", # Missing docstring in public nested class "D107", # Missing docstring in __init__ "D413", # Missing blank line after last section ] [tool.ruff.lint.per-file-ignores] "tests/**" = [ "ANN", # flake8-annotations "ARG001", # Unused function argument "ARG002", # Unused method argument "FBT001", # Boolean-typed positional argument in function definition "SLF001", # Private member accessed "T201", # Usage of `print` ] "docs/**" = [ "INP001", # Implicit namespace package ] "docs/examples/code/**" = [ "FA", # flake8-future-annotations "BLE001", # Do not catch blind exception: `Exception` "T201", # Usage of `print` "TC001", # Move application import into a type-checking block ] [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/status_response.py" = "mcstatus/status_response.py" "mcstatus/_compat/forge_data.py" = "mcstatus/forge_data.py" "mcstatus/_compat/motd_transformers.py" = "mcstatus/motd/transformers.py" ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/helpers.py ================================================ import 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.Iterator[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() ================================================ FILE: tests/motd/__init__.py ================================================ ================================================ FILE: tests/motd/conftest.py ================================================ import pytest @pytest.fixture(scope="session") def source_java() -> dict: """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() -> dict: """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": "minecoin_gold", "text": "20"}, {"color": "material_quartz", "text": "21"}, {"color": "material_iron", "text": "22"}, {"color": "material_netherite", "text": "23"}, {"color": "material_redstone", "text": "24"}, {"color": "material_copper", "text": "25"}, {"color": "material_gold", "text": "26"}, {"color": "material_emerald", "text": "27"}, {"color": "material_diamond", "text": "28"}, {"color": "material_lapis", "text": "29"}, {"color": "material_amethyst", "text": "30"}, {"color": "material_resin", "text": "31"}, {"color": "reset", "text": "32"}, {"translate": "some.random.string"}, ], "text": "top", } ================================================ FILE: tests/motd/test_components.py ================================================ from __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_, rgb): 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_, rgb): 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" ================================================ FILE: tests/motd/test_motd.py ================================================ from __future__ import annotations import pytest from mcstatus.motd import Motd from mcstatus.motd.components import Formatting, MinecraftColor, TranslationTag, WebColor from mcstatus.responses._raw import RawJavaResponseMotdWhenDict class TestMotdParse: def test_correct_result(self, source_bedrock): assert Motd.parse(source_bedrock) == Motd( [ "top", Formatting.RESET, "1", Formatting.RESET, WebColor.from_hex(hex="#b3eeff"), "2", Formatting.RESET, MinecraftColor.BLACK, Formatting.OBFUSCATED, "3", Formatting.RESET, MinecraftColor.DARK_BLUE, Formatting.BOLD, Formatting.STRIKETHROUGH, "4", Formatting.RESET, MinecraftColor.DARK_GREEN, Formatting.ITALIC, "5", Formatting.RESET, MinecraftColor.DARK_AQUA, Formatting.UNDERLINED, "6", Formatting.RESET, MinecraftColor.DARK_AQUA, "7", Formatting.RESET, MinecraftColor.DARK_RED, "8", Formatting.RESET, MinecraftColor.DARK_PURPLE, "9", Formatting.RESET, MinecraftColor.GOLD, "10", Formatting.RESET, MinecraftColor.GRAY, "11", Formatting.RESET, MinecraftColor.DARK_GRAY, "12", Formatting.RESET, MinecraftColor.BLUE, "13", Formatting.RESET, MinecraftColor.GREEN, "14", Formatting.RESET, MinecraftColor.AQUA, "15", Formatting.RESET, MinecraftColor.RED, "16", Formatting.RESET, MinecraftColor.LIGHT_PURPLE, "17", Formatting.RESET, MinecraftColor.YELLOW, "18", Formatting.RESET, MinecraftColor.WHITE, "19", Formatting.RESET, MinecraftColor.MINECOIN_GOLD, "20", Formatting.RESET, MinecraftColor.MATERIAL_QUARTZ, "21", Formatting.RESET, MinecraftColor.MATERIAL_IRON, "22", Formatting.RESET, MinecraftColor.MATERIAL_NETHERITE, "23", Formatting.RESET, MinecraftColor.MATERIAL_REDSTONE, "24", Formatting.RESET, MinecraftColor.MATERIAL_COPPER, "25", Formatting.RESET, MinecraftColor.MATERIAL_GOLD, "26", Formatting.RESET, MinecraftColor.MATERIAL_EMERALD, "27", Formatting.RESET, MinecraftColor.MATERIAL_DIAMOND, "28", Formatting.RESET, MinecraftColor.MATERIAL_LAPIS, "29", Formatting.RESET, MinecraftColor.MATERIAL_AMETHYST, "30", Formatting.RESET, MinecraftColor.MATERIAL_RESIN, "31", Formatting.RESET, Formatting.RESET, "32", Formatting.RESET, TranslationTag("some.random.string"), Formatting.RESET, ], raw=source_bedrock, ) # fmt: skip @pytest.mark.parametrize("bedrock", [True, False]) def test_bedrock_parameter_nothing_changes(self, bedrock: bool): assert Motd.parse([{"color": "minecoin_gold", "text": " "}], bedrock=bedrock).parsed == [ Formatting.RESET, MinecraftColor.MINECOIN_GOLD, " ", Formatting.RESET, ] @pytest.mark.parametrize(("bedrock", "expected"), [(True, MinecraftColor.MINECOIN_GOLD), (False, "&g")]) def test_parse_as_str_ignore_minecoin_gold_on_java(self, bedrock: bool, expected): assert Motd.parse("&g", bedrock=bedrock).parsed == [expected] def test_parse_incorrect_color_passes(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r985084188`_.""" assert Motd.parse("&z").parsed == ["&z"] def test_parse_uppercase_passes(self): assert Motd.parse("&A").parsed == ["", MinecraftColor.GREEN, ""] @pytest.mark.parametrize( ("input_", "expected"), [("", [""]), ([], [Formatting.RESET]), ({"extra": [], "text": ""}, ["", Formatting.RESET])] ) def test_empty_input_also_empty_raw(self, input_, expected): 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 == [ Formatting.BOLD, "top", Formatting.RESET, Formatting.BOLD, MinecraftColor.RED, "not top", Formatting.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 == [ Formatting.BOLD, "bold", Formatting.RESET, MinecraftColor.RED, "not bold", Formatting.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 == [ Formatting.BOLD, "top", Formatting.RESET, Formatting.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 == [ MinecraftColor.RED, "not bold", Formatting.RESET, ] def test_translate_string(self): assert Motd.parse(RawJavaResponseMotdWhenDict(translate="the key")).parsed == [ TranslationTag("the key"), Formatting.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 == ["", MinecraftColor.GREEN, "Hello!", Formatting.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_invalid_color(self): with pytest.raises(ValueError, match=r"^Unable to parse color: 'a', report this!$"): Motd._parse_color("a") 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 == [ Formatting.RESET, Formatting.RESET, Formatting.RESET, "1", Formatting.RESET, Formatting.RESET, "2", Formatting.RESET, Formatting.RESET, "3", Formatting.RESET, Formatting.RESET, Formatting.RESET, "4", Formatting.RESET, Formatting.RESET, "5", Formatting.RESET, Formatting.RESET, "6", Formatting.RESET, Formatting.RESET, Formatting.RESET, "7", Formatting.RESET, Formatting.RESET, "8", Formatting.RESET, Formatting.RESET, "9", Formatting.RESET, ] # fmt: skip def test_raw_attribute(self, source_bedrock): motd = Motd.parse(source_bedrock) assert motd.raw == source_bedrock ================================================ FILE: tests/motd/test_simplifies.py ================================================ from __future__ import annotations from contextlib import ExitStack from unittest import mock import pytest from mcstatus.motd import Motd from mcstatus.motd._simplifies import ( get_double_colors, get_double_items, get_empty_text, get_end_non_text, get_formatting_before_color, get_meaningless_resets_and_colors, get_unused_elements, ) from mcstatus.motd.components import Formatting, MinecraftColor, 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_empty_text.__name__, get_end_non_text.__name__, ] ] get_unused_elements([]) for simplifier in mocked: simplifier.assert_called() def test_simplify_returns_new_instance(self): parsed = ["", Formatting.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([Formatting.BOLD, "", Formatting.RESET, "", MinecraftColor.RED, ""], raw="") assert obj.simplify() == Motd([], raw="") @pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) def test_get_double_colors(self, first, second): assert get_double_colors([first, second]) == {0} @pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) @pytest.mark.parametrize("third", [MinecraftColor.BLUE, WebColor.from_hex(hex="dd0220")]) def test_get_double_colors_with_three_items(self, first, second, third): assert get_double_colors([first, second, third]) == {0, 1} @pytest.mark.parametrize("first", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) @pytest.mark.parametrize("second", [MinecraftColor.BLUE, WebColor.from_hex(hex="#dd0220")]) def test_get_double_colors_with_no_double_colors(self, first, second): assert get_double_colors([first, "", second]) == set() @pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items(self, item): assert get_double_items([item, item]) == {0} @pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items_with_three_items(self, item): assert get_double_items([item, item, item]) == {0, 1} @pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_double_items_with_no_double_items(self, item): assert get_double_items([item, "", item]) == set() @pytest.mark.parametrize("last_item", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_formatting_before_color(self, last_item): assert get_formatting_before_color([Formatting.BOLD, last_item]) == {0} @pytest.mark.parametrize("first_item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000"), "abc"]) def test_get_formatting_before_color_without_formatting_before_color(self, first_item): assert get_formatting_before_color([first_item, "abc", MinecraftColor.WHITE]) == set() def test_skip_get_formatting_before_color(self): assert get_formatting_before_color(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi"]) == set() @pytest.mark.parametrize("last_item", [MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_get_formatting_before_color_if_space_between(self, last_item): assert get_formatting_before_color([Formatting.BOLD, " ", last_item]) == {0} def test_get_empty_text_removes_empty_string(self): assert get_empty_text([Formatting.BOLD, "", Formatting.RESET, "", MinecraftColor.RED, ""]) == {1, 3, 5} def test_two_formattings_before_minecraft_color(self): """See `https://github.com/py-mine/mcstatus/pull/335#discussion_r1048476090`_.""" assert get_formatting_before_color([Formatting.BOLD, Formatting.ITALIC, MinecraftColor.RED]) == {0, 1} def test_two_formattings_one_by_one(self): obj = Motd([Formatting.BOLD, Formatting.ITALIC], raw="") assert obj.simplify().parsed == [] @pytest.mark.parametrize("item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff1234")]) def test_dont_remove_empty_text(self, item): assert get_empty_text([item]) == set() @pytest.mark.parametrize("last_item", [Formatting.RESET, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_non_text_in_the_end(self, last_item): assert get_end_non_text(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi", last_item]) == {5} def test_translation_tag_in_the_end(self): assert get_end_non_text(["abc", Formatting.BOLD, "def", Formatting.RESET, "ghi", TranslationTag("key")]) == set() @pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_meaningless_resets_and_colors_active(self, item): 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", Formatting.RESET, "bar"]) == {1} @pytest.mark.parametrize("item", [Formatting.BOLD, MinecraftColor.RED, WebColor.from_hex(hex="#ff0000")]) def test_meaningless_resets_and_colors_resets(self, item): assert get_meaningless_resets_and_colors([item, "foo", Formatting.RESET, item, "bar"]) == set() def test_no_conflict_on_poping_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(*_, **__): 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_empty_text.__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 == [MinecraftColor.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", Formatting.RESET, "123", Formatting.RESET] assert motd.simplify().parsed == ["123123"] def test_squash_nearby_strings(self): assert Motd(["123", "123", "123"], raw="").simplify().parsed == ["123123123"] ================================================ FILE: tests/motd/test_transformers.py ================================================ # ruff: noqa: FBT003 # boolean positional value in `result` fixture from __future__ import annotations import typing import pytest from mcstatus.motd import Motd from mcstatus.motd._transformers import _NothingTransformer if typing.TYPE_CHECKING: from collections.abc import Callable from mcstatus.responses._raw import RawJavaResponseMotd def test_nothing_transformer(): assert _NothingTransformer().transform(Motd.parse("&1a&bfoo&r").parsed) == "" class TestMotdPlain: @pytest.fixture(scope="class") def result(self) -> Callable[[str | RawJavaResponseMotd], str]: return lambda text: Motd.parse(text).to_plain() def test_plain_text(self, result): assert result("plain") == "plain" def test_removes_colors(self, result): assert result("&1<ext") == "text" def test_skip_web_colors(self, result): assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "colored text" def test_skip_minecraft_colors(self, result): assert result({"extra": [{"color": "red", "text": "colored text"}], "text": ""}) == "colored text" class TestMotdMinecraft: @pytest.fixture(scope="class") def result(self) -> Callable[[str | RawJavaResponseMotd], str]: return lambda text: Motd.parse(text).to_minecraft() @pytest.mark.parametrize("motd", ["&1&2&3", "§123§5bc", "§1§2§3"]) def test_return_the_same(self, motd: str, result): assert result(motd) == motd.replace("&", "§") def test_skip_web_colors(self, result): assert result({"extra": [{"color": "#4000ff", "text": "colored text"}], "text": ""}) == "§rcolored text§r" class TestMotdHTML: @pytest.fixture(scope="class") def result(self) -> Callable[[str, bool], str]: return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_html() def test_correct_output_java(self, result: Callable[[str | dict, bool], str], source_java): 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: Callable[[str | dict, bool], str], source_bedrock): assert result(source_bedrock, True) == ( "

top" "12" "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" "29" "30" "31" "32

" ) 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") def result(self) -> Callable[[str, bool], str]: return lambda text, bedrock: Motd.parse(text, bedrock=bedrock).to_ansi() def test_correct_output_java(self, result: Callable[[str | dict, bool], str], source_java): 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: Callable[[str | dict, bool], str], source_bedrock): assert result(source_bedrock, True) == ( "\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;198;198;198m11\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" "\033[38;2;221;214;5m20\033[0m\033[0m" "\033[38;2;227;212;209m21\033[0m\033[0m" "\033[38;2;206;202;202m22\033[0m\033[0m" "\033[38;2;68;58;59m23\033[0m\033[0m" "\033[38;2;151;22;7m24\033[0m\033[0m" "\033[38;2;180;104;77m25\033[0m\033[0m" "\033[38;2;222;177;45m26\033[0m\033[0m" "\033[38;2;17;159;54m27\033[0m\033[0m" "\033[38;2;44;186;168m28\033[0m\033[0m" "\033[38;2;33;73;123m29\033[0m\033[0m" "\033[38;2;154;92;198m30\033[0m\033[0m" "\033[38;2;235;114;20m31\033[0m\033[0m" "32\033[0m" "\033[0m\033[0m" ) ================================================ FILE: tests/net/__init__.py ================================================ ================================================ FILE: tests/net/test_address.py ================================================ from __future__ import annotations import ipaddress import sys from pathlib import Path from typing import cast from unittest.mock import MagicMock, Mock, patch import dns.resolver import pytest from dns.rdatatype import RdataType from mcstatus._net.address import Address, async_minecraft_srv_address_lookup, minecraft_srv_address_lookup class TestSRVLookup: @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) def test_address_no_srv(self, exception): with patch("dns.resolver.resolve") as resolve: resolve.side_effect = [exception] address = minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) 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): with patch("dns.resolver.resolve") as resolve: resolve.side_effect = [exception] with pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"): minecraft_srv_address_lookup("example.org", lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) def test_address_with_srv(self): with patch("dns.resolver.resolve") as resolve: answer = Mock() answer.target = "different.example.org." answer.port = 12345 resolve.return_value = [answer] address = minecraft_srv_address_lookup("example.org", lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) assert address.host == "different.example.org" assert address.port == 12345 @pytest.mark.asyncio @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) async def test_async_address_no_srv(self, exception): with patch("dns.asyncresolver.resolve") as resolve: resolve.side_effect = [exception] address = await async_minecraft_srv_address_lookup("example.org", default_port=25565, lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) assert address.host == "example.org" assert address.port == 25565 @pytest.mark.asyncio @pytest.mark.parametrize("exception", [dns.resolver.NXDOMAIN, dns.resolver.NoAnswer]) async def test_async_address_no_srv_no_default_port(self, exception): with patch("dns.asyncresolver.resolve") as resolve: resolve.side_effect = [exception] with pytest.raises(ValueError, match=r"^Given address 'example.org' doesn't contain port"): await async_minecraft_srv_address_lookup("example.org", lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) @pytest.mark.asyncio async def test_async_address_with_srv(self): with patch("dns.asyncresolver.resolve") as resolve: answer = Mock() answer.target = "different.example.org." answer.port = 12345 resolve.return_value = [answer] address = await async_minecraft_srv_address_lookup("example.org", lifetime=3) resolve.assert_called_once_with("_minecraft._tcp.example.org", RdataType.SRV, lifetime=3, search=True) assert address.host == "different.example.org" assert address.port == 12345 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, port): Address._ensure_validity(address, port) @pytest.mark.parametrize( ("address", "port"), [ ("example.org", 100_000), ("example.org", -1), ], ) def test_address_validation_range(self, address, port): 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, port): 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@#") 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") 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("dns.resolver.resolve") as resolve: answer = MagicMock() cast("MagicMock", answer.__str__).return_value = "48.225.1.104." resolve.return_value = [answer] resolved_ip = self.host_addr.resolve_ip(lifetime=3) resolve.assert_called_once_with(self.host_addr.host, RdataType.A, lifetime=3, search=True) assert isinstance(resolved_ip, ipaddress.IPv4Address) assert str(resolved_ip) == "48.225.1.104" @pytest.mark.asyncio async def test_async_ip_resolver_with_hostname(self): with patch("dns.asyncresolver.resolve") as resolve: answer = MagicMock() cast("MagicMock", answer.__str__).return_value = "48.225.1.104." resolve.return_value = [answer] resolved_ip = await self.host_addr.async_resolve_ip(lifetime=3) resolve.assert_called_once_with(self.host_addr.host, RdataType.A, lifetime=3, search=True) 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.asyncio @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 @pytest.mark.asyncio 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 @pytest.mark.asyncio 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") @pytest.mark.asyncio 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") ================================================ FILE: tests/protocol/__init__.py ================================================ ================================================ FILE: tests/protocol/test_async_support.py ================================================ from inspect import iscoroutinefunction from mcstatus._protocol.connection import TCPAsyncSocketConnection, UDPAsyncSocketConnection def test_is_completely_asynchronous(): conn = TCPAsyncSocketConnection assertions = 0 for attribute in dir(conn): if attribute.startswith("read_"): assert iscoroutinefunction(getattr(conn, attribute)) assertions += 1 assert assertions > 0, "None of the read_* attributes were async" def test_query_is_completely_asynchronous(): conn = UDPAsyncSocketConnection assertions = 0 for attribute in dir(conn): if attribute.startswith("read_"): assert iscoroutinefunction(getattr(conn, attribute)) assertions += 1 assert assertions > 0, "None of the read_* attributes were async" ================================================ FILE: tests/protocol/test_bedrock_client.py ================================================ import 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.asyncio @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 ================================================ FILE: tests/protocol/test_connection.py ================================================ from unittest.mock import Mock, patch import pytest from mcstatus._net.address import Address from mcstatus._protocol.connection import Connection, TCPSocketConnection, UDPSocketConnection class TestConnection: connection: Connection def setup_method(self): self.connection = Connection() def test_flush(self): self.connection.sent = bytearray.fromhex("7FAABB") assert self.connection.flush() == bytearray.fromhex("7FAABB") assert self.connection.sent == bytearray() def test_receive(self): self.connection.receive(bytearray.fromhex("7F")) self.connection.receive(bytearray.fromhex("AABB")) assert self.connection.received == bytearray.fromhex("7FAABB") def test_remaining(self): self.connection.receive(bytearray.fromhex("7F")) self.connection.receive(bytearray.fromhex("AABB")) assert self.connection.remaining() == 3 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.receive(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, value) -> None: self.connection.receive(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.receive(bytearray.fromhex("FFFFFFFF80")) with pytest.raises(IOError, match=r"^Received varint is too big!$"): self.connection.read_varint() def test_write_invalid_varint(self): with pytest.raises(ValueError, match=r'^The value "2147483648" is too big to send in a varint$'): self.connection.write_varint(2147483648) with pytest.raises(ValueError, match=r'^The value "-2147483649" is too big to send in a varint$'): self.connection.write_varint(-2147483649) def test_read_utf(self): self.connection.receive(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.receive(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.receive(bytearray.fromhex("8000")) assert self.connection.read_short() == -32768 def test_write_short_negative(self): self.connection.write_short(-32768) assert self.connection.flush() == bytearray.fromhex("8000") def test_read_short_positive(self): self.connection.receive(bytearray.fromhex("7FFF")) assert self.connection.read_short() == 32767 def test_write_short_positive(self): self.connection.write_short(32767) assert self.connection.flush() == bytearray.fromhex("7FFF") def test_read_ushort_positive(self): self.connection.receive(bytearray.fromhex("8000")) assert self.connection.read_ushort() == 32768 def test_write_ushort_positive(self): self.connection.write_ushort(32768) assert self.connection.flush() == bytearray.fromhex("8000") def test_read_int_negative(self): self.connection.receive(bytearray.fromhex("80000000")) assert self.connection.read_int() == -2147483648 def test_write_int_negative(self): self.connection.write_int(-2147483648) assert self.connection.flush() == bytearray.fromhex("80000000") def test_read_int_positive(self): self.connection.receive(bytearray.fromhex("7FFFFFFF")) assert self.connection.read_int() == 2147483647 def test_write_int_positive(self): self.connection.write_int(2147483647) assert self.connection.flush() == bytearray.fromhex("7FFFFFFF") def test_read_uint_positive(self): self.connection.receive(bytearray.fromhex("80000000")) assert self.connection.read_uint() == 2147483648 def test_write_uint_positive(self): self.connection.write_uint(2147483648) assert self.connection.flush() == bytearray.fromhex("80000000") def test_read_long_negative(self): self.connection.receive(bytearray.fromhex("8000000000000000")) assert self.connection.read_long() == -9223372036854775808 def test_write_long_negative(self): self.connection.write_long(-9223372036854775808) assert self.connection.flush() == bytearray.fromhex("8000000000000000") def test_read_long_positive(self): self.connection.receive(bytearray.fromhex("7FFFFFFFFFFFFFFF")) assert self.connection.read_long() == 9223372036854775807 def test_write_long_positive(self): self.connection.write_long(9223372036854775807) assert self.connection.flush() == bytearray.fromhex("7FFFFFFFFFFFFFFF") def test_read_ulong_positive(self): self.connection.receive(bytearray.fromhex("8000000000000000")) assert self.connection.read_ulong() == 9223372036854775808 def test_write_ulong_positive(self): self.connection.write_ulong(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.receive(bytearray.fromhex(as_bytes)) assert self.connection.read_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_bool(as_bool) assert self.connection.flush() == bytearray.fromhex(as_bytes) def test_read_buffer(self): self.connection.receive(bytearray.fromhex("027FAA")) buffer = self.connection.read_buffer() assert buffer.received == bytearray.fromhex("7FAA") assert self.connection.flush() == bytearray() def test_write_buffer(self): buffer = Connection() buffer.write(bytearray.fromhex("7FAA")) self.connection.write_buffer(buffer) assert self.connection.flush() == bytearray.fromhex("027FAA") def test_read_empty(self): self.connection.received = bytearray() with pytest.raises(IOError, match=r"^Not enough data to read! 0 < 1$"): self.connection.read(1) def test_read_not_enough(self): self.connection.received = bytearray(b"a") with pytest.raises(IOError, match=r"^Not enough data to read! 1 < 2$"): self.connection.read(2) class TestTCPSocketConnection: @pytest.fixture(scope="class") def connection(self): test_addr = Address("localhost", 1234) socket = Mock() socket.recv = Mock() socket.send = Mock() with patch("socket.create_connection") as create_connection: create_connection.return_value = socket with TCPSocketConnection(test_addr) as connection: yield connection def test_flush(self, connection): with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support flush\(\)$"): connection.flush() def test_receive(self, connection): with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support receive\(\)$"): connection.receive("") def test_remaining(self, connection): with pytest.raises(TypeError, match=r"^TCPSocketConnection does not support remaining\(\)$"): connection.remaining() def test_read(self, connection): connection.socket.recv.return_value = bytearray.fromhex("7FAA") assert connection.read(2) == bytearray.fromhex("7FAA") def test_read_empty(self, connection): 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): 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): connection.write(bytearray.fromhex("7FAA")) connection.socket.send.assert_called_once_with(bytearray.fromhex("7FAA")) class TestUDPSocketConnection: @pytest.fixture(scope="class") def connection(self): test_addr = Address("localhost", 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_flush(self, connection): with pytest.raises(TypeError, match=r"^UDPSocketConnection does not support flush\(\)$"): connection.flush() def test_receive(self, connection): with pytest.raises(TypeError, match=r"^UDPSocketConnection does not support receive\(\)$"): connection.receive("") def test_remaining(self, connection): assert connection.remaining() == 65535 def test_read(self, connection): connection.socket.recvfrom.return_value = [bytearray.fromhex("7FAA")] assert connection.read(2) == bytearray.fromhex("7FAA") def test_read_empty(self, connection): connection.socket.recvfrom.return_value = [] with pytest.raises(IndexError, match=r"^list index out of range$"): connection.read(1) def test_write(self, connection): connection.write(bytearray.fromhex("7FAA")) connection.socket.sendto.assert_called_once_with( bytearray.fromhex("7FAA"), Address("localhost", 1234), ) ================================================ FILE: tests/protocol/test_java_client.py ================================================ import sys import time from unittest import mock import pytest from mcstatus._net.address import Address from mcstatus._protocol.connection import Connection from mcstatus._protocol.java_client import JavaClient class TestJavaClient: def setup_method(self): self.java_client = JavaClient( Connection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) def test_handshake(self): self.java_client.handshake() assert self.java_client.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01") def test_read_status(self): self.java_client.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.java_client.connection.flush() == bytearray.fromhex("0100") def test_read_status_invalid_json(self): self.java_client.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.java_client.connection.receive( # no motd, see also #922 bytearray.fromhex( "4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616" "D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D" ) ) self.java_client.read_status() def test_read_status_invalid_status(self): self.java_client.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.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 14515484 assert self.java_client.test_ping() >= 0 assert self.java_client.connection.flush() == bytearray.fromhex("09010000000000DD7D1C") def test_test_ping_invalid(self): self.java_client.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.java_client.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() @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) def test_latency_is_real_number(self): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_buffer(): time.sleep(0.001) return mock.DEFAULT with mock.patch.object(Connection, "read_buffer") as mocked: mocked.side_effect = mocked_read_buffer mocked.return_value.read_varint.return_value = 0 mocked.return_value.read_utf.return_value = """ { "description": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44} } """ java_client = JavaClient( Connection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) java_client.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A" "7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531" "222C2270726F746F636F6C223A34347D7D" ) ) # we slept 1ms, so this should be always ~1. assert java_client.read_status().latency >= 1 @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) def test_test_ping_is_in_milliseconds(self): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_buffer(): time.sleep(0.001) return mock.DEFAULT with mock.patch.object(Connection, "read_buffer") as mocked: mocked.side_effect = mocked_read_buffer mocked.return_value.read_varint.return_value = 1 mocked.return_value.read_long.return_value = 123456789 java_client = JavaClient( Connection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ping_token=123456789, ) java_client.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A" "7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531" "222C2270726F746F636F6C223A34347D7D" ) ) # we slept 1ms, so this should be always ~1. assert java_client.test_ping() >= 1 ================================================ FILE: tests/protocol/test_java_client_async.py ================================================ import asyncio import sys import time from unittest import mock import pytest from mcstatus._net.address import Address from mcstatus._protocol.connection import Connection from mcstatus._protocol.java_client import AsyncJavaClient def async_decorator(f): def wrapper(*args, **kwargs): return asyncio.run(f(*args, **kwargs)) return wrapper class FakeAsyncConnection(Connection): async def read_buffer(self): # pyright: ignore[reportIncompatibleMethodOverride] return super().read_buffer() class TestAsyncJavaClient: def setup_method(self): self.java_client = AsyncJavaClient( FakeAsyncConnection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) def test_handshake(self): self.java_client.handshake() assert self.java_client.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01") def test_read_status(self): self.java_client.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.java_client.connection.flush() == bytearray.fromhex("0100") def test_read_status_invalid_json(self): self.java_client.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.java_client.connection.receive( bytearray.fromhex( "4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616" "D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D" ) ) async_decorator(self.java_client.read_status)() def test_read_status_invalid_status(self): self.java_client.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.java_client.connection.receive(bytearray.fromhex("09010000000000DD7D1C")) self.java_client.ping_token = 14515484 assert async_decorator(self.java_client.test_ping)() >= 0 assert self.java_client.connection.flush() == bytearray.fromhex("09010000000000DD7D1C") def test_test_ping_invalid(self): self.java_client.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.java_client.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)() @pytest.mark.asyncio @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) async def test_latency_is_real_number(self): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_buffer(): time.sleep(0.001) return mock.DEFAULT with mock.patch.object(FakeAsyncConnection, "read_buffer") as mocked: mocked.side_effect = mocked_read_buffer # overwrite `async` here mocked.return_value.read_varint = lambda: 0 mocked.return_value.read_utf = lambda: ( """ { "description": "A Minecraft Server", "players": {"max": 20, "online": 0}, "version": {"name": "1.8-pre1", "protocol": 44} } """ ) java_client = AsyncJavaClient( FakeAsyncConnection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ) java_client.connection.receive( bytearray.fromhex( "7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A" "7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531" "222C2270726F746F636F6C223A34347D7D" ) ) # we slept 1ms, so this should be always ~1. assert (await java_client.read_status()).latency >= 1 @pytest.mark.asyncio @pytest.mark.flaky(reruns=5, condition=sys.platform.startswith("win32")) async def test_test_ping_is_in_milliseconds(self): """``time.perf_counter`` returns fractional seconds, we must convert it to milliseconds.""" def mocked_read_buffer(): time.sleep(0.001) return mock.DEFAULT with mock.patch.object(FakeAsyncConnection, "read_buffer") as mocked: mocked.side_effect = mocked_read_buffer mocked.return_value.read_varint = lambda: 1 # overwrite `async` here mocked.return_value.read_long = lambda: 123456789 # overwrite `async` here java_client = AsyncJavaClient( FakeAsyncConnection(), # pyright: ignore[reportArgumentType] address=Address("localhost", 25565), version=44, ping_token=123456789, ) # we slept 1ms, so this should be always ~1. assert await java_client.test_ping() >= 1 ================================================ FILE: tests/protocol/test_legacy_client.py ================================================ import pytest from mcstatus._protocol.connection import Connection from mcstatus._protocol.legacy_client import LegacyClient from mcstatus.motd import Motd from mcstatus.responses.legacy import LegacyStatusPlayers, LegacyStatusResponse, LegacyStatusVersion 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 = Connection() socket.receive(bytearray.fromhex("00")) server = LegacyClient(socket) with pytest.raises(IOError, match=r"^Received invalid packet ID$"): server.read_status() ================================================ FILE: tests/protocol/test_query_client.py ================================================ from unittest.mock import Mock from mcstatus._protocol.connection import Connection from mcstatus._protocol.query_client import QueryClient from mcstatus.motd import Motd class TestQueryClient: def setup_method(self): self.query_client = QueryClient(Connection()) # pyright: ignore[reportArgumentType] def test_handshake(self): self.query_client.connection.receive(bytearray.fromhex("090000000035373033353037373800")) self.query_client.handshake() conn_bytes = self.query_client.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert self.query_client.challenge == 570350778 def test_query(self): self.query_client.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) response = self.query_client.read_query() conn_bytes = self.query_client.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.query_client.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.query_client.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.query_client.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.query_client.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.query_client.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d6500006f746865720067616d657479706500534d500067616d655f6964004d" "494e4543524146540076657273696f6e00312e31382e3100706c7567696e7300006d617000776f726c64006e756d706c617965727300" "30006d6178706c617965727300323000686f7374706f727400323535363500686f73746970003137322e31372e302e32000001706c61" "7965725f000000" ) ) response = self.query_client.read_query() self.query_client.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.query_client.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.query_client.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 ================================================ FILE: tests/protocol/test_query_client_async.py ================================================ from mcstatus._protocol.connection import Connection from mcstatus._protocol.query_client import AsyncQueryClient from tests.protocol.test_java_client_async import async_decorator class FakeUDPAsyncConnection(Connection): async def read(self, length): # pyright: ignore[reportIncompatibleMethodOverride] return super().read(length) async def write(self, data): # pyright: ignore[reportIncompatibleMethodOverride] return super().write(data) class TestAsyncQueryClient: def setup_method(self): self.query_client = AsyncQueryClient(FakeUDPAsyncConnection()) # pyright: ignore[reportArgumentType] def test_handshake(self): self.query_client.connection.receive(bytearray.fromhex("090000000035373033353037373800")) async_decorator(self.query_client.handshake)() conn_bytes = self.query_client.connection.flush() assert conn_bytes[:3] == bytearray.fromhex("FEFD09") assert self.query_client.challenge == 570350778 def test_query(self): self.query_client.connection.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) response = async_decorator(self.query_client.read_query)() conn_bytes = self.query_client.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"] ================================================ FILE: tests/protocol/test_timeout.py ================================================ import asyncio import typing from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from unittest.mock import patch import pytest from mcstatus._net.address import Address from mcstatus._protocol.connection import TCPAsyncSocketConnection class FakeAsyncStream(asyncio.StreamReader): async def read(self, *args, **kwargs) -> typing.NoReturn: await asyncio.sleep(2) raise NotImplementedError("tests are designed to timeout before reaching this line") async def fake_asyncio_asyncio_open_connection(hostname: str, port: int): return FakeAsyncStream(), None class TestAsyncSocketConnection: @pytest.mark.asyncio 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) ================================================ FILE: tests/responses/__init__.py ================================================ from __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: raw = cast("tuple", 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_ ================================================ FILE: tests/responses/conftest.py ================================================ from __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: if metafunc.cls is not None and issubclass(typing.cast("type", metafunc.cls), BaseResponseTest): instance = typing.cast("type", metafunc.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)) ================================================ FILE: tests/responses/test_base.py ================================================ import 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] ================================================ FILE: tests/responses/test_bedrock.py ================================================ import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import BedrockStatusPlayers, BedrockStatusResponse, BedrockStatusVersion from tests.helpers import patch_project_version 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, ) @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") def build(self, build): return build @pytest.mark.parametrize(("field", "pop_index"), [("map_name", 7), ("gamemode", 7), ("gamemode", 8)]) def test_optional_parameters_is_none(self, field, pop_index): 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" @BaseResponseTest.construct class TestBedrockStatusPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("online", 1), ("max", 69)] @pytest.fixture(scope="class") def build(self, build): return build.players @BaseResponseTest.construct class TestBedrockStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.18.100500"), ("protocol", 422), ("brand", "MCPE")] @pytest.fixture(scope="class") def build(self, build): return build.version def test_deprecated_version_alias(self, build: BedrockStatusVersion): with ( patch_project_version("0.0.0"), pytest.deprecated_call( match=( r"^BedrockStatusVersion\.version is deprecated and scheduled for removal in 13\.0\.0, " r"use name instead\.$" ), ), ): assert build.version == build.name ================================================ FILE: tests/responses/test_forge_data.py ================================================ import 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", } @BaseResponseTest.construct class TestForgeDataV1(BaseResponseTest): RAW: t.ClassVar = { "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") def build(self) -> ForgeData: return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict @BaseResponseTest.construct class TestForgeDataV2(BaseResponseTest): RAW: t.ClassVar = { "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") def build(self) -> ForgeData: return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict @BaseResponseTest.construct class TestForgeDataV3(BaseResponseTest): RAW: t.ClassVar = { "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") def build(self) -> ForgeData: return ForgeData.build(self.RAW) # pyright: ignore[reportArgumentType] # dict[str, Unknown] cannot be assigned to TypedDict 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"}) @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") def build(self) -> 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): # 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 ================================================ FILE: tests/responses/test_java.py ================================================ import typing as t import pytest from mcstatus.motd import Motd from mcstatus.responses import JavaStatusPlayer, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion from tests.responses import BaseResponseTest @BaseResponseTest.construct class TestJavaStatusResponse(BaseResponseTest): RAW: t.ClassVar = { "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), ] 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") def build(self) -> JavaStatusResponse: return JavaStatusResponse.build(self.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", "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" @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") def build(self) -> 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 ) @BaseResponseTest.construct class TestJavaStatusPlayer(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "foo"), ("id", "0b3717c4-f45d-47c8-b8e2-3d9ff6f93a89")] @pytest.fixture(scope="class") def build(self) -> 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 @BaseResponseTest.construct class TestJavaStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.8-pre1"), ("protocol", 44)] @pytest.fixture(scope="class") def build(self) -> JavaStatusVersion: return JavaStatusVersion.build({"name": "1.8-pre1", "protocol": 44}) ================================================ FILE: tests/responses/test_legacy.py ================================================ import 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, ) @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") def build(self, build): 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}, } @BaseResponseTest.construct class TestLegacyStatusPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("online", 0), ("max", 20)] @pytest.fixture(scope="class") def build(self, build): return build.players @BaseResponseTest.construct class TestLegacyStatusVersion(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [("name", "1.4.2"), ("protocol", 47)] @pytest.fixture(scope="class") def build(self, build): return build.version ================================================ FILE: tests/responses/test_query.py ================================================ import 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.helpers import patch_project_version from tests.responses import BaseResponseTest @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") def build(self): return QueryResponse.build(raw=self.RAW, players_list=self.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", }, } def test_deprecated_map_alias(self, build: QueryResponse): with ( patch_project_version("0.0.0"), pytest.deprecated_call( match=r"^QueryResponse\.map is deprecated and scheduled for removal in 13\.0\.0, use map_name instead\.$", ), ): assert build.map == build.map_name @BaseResponseTest.construct class TestQueryPlayers(BaseResponseTest): EXPECTED_VALUES: t.ClassVar = [ ("online", 3), ("max", 20), ("list", ["Dinnerbone", "Djinnibone", "Steve"]), ] @pytest.fixture(scope="class") def build(self): 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"], ) def test_deprecated_names_alias(self, build: QueryPlayers): with ( patch_project_version("0.0.0"), pytest.deprecated_call( match=( r"^QueryPlayers\.names is deprecated and scheduled for removal in 13\.0\.0, " r"use 'list' attribute instead\.$" ), ), ): assert build.names == build.list 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 == [] ================================================ FILE: tests/test_cli.py ================================================ import contextlib import io import json import os import socket 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 = [ { "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. """ # noqa: E501 (line length) @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(): 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): 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): 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): 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): 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): 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): 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): 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): 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): 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): 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): 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, }, "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): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "ping"]) == 0 assert float(out.getvalue()) == 0 assert err.getvalue() == "" def test_ping_bedrock(mock_network_requests): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--bedrock", "ping"]) == 0 assert float(out.getvalue()) == 123 assert err.getvalue() == "" def test_ping_legacy(mock_network_requests): with patch_stdout_stderr() as (out, err): assert main_under_test(["example.com", "--legacy", "ping"]) == 0 assert float(out.getvalue()) == 123 assert err.getvalue() == "" def test_ping_server_doesnt_support(mock_network_requests): 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 assert err.getvalue() == PING_PACKET_FAIL_WARNING.format(address="example.com:25565", ping_exc="timeout") + "\n" ================================================ FILE: tests/test_compat.py ================================================ """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 Iterator 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) -> Iterator[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) # noqa: S202 @pytest.mark.parametrize("raises", [False, True]) @pytest.mark.parametrize( ("module", "msg_pattern"), [ ("mcstatus._compat.forge_data", r"use mcstatus\.responses\.forge instead"), ("mcstatus._compat.motd_transformers", r"MOTD Transformers are no longer a part of mcstatus public API"), ("mcstatus._compat.status_response", r"use mcstatus\.responses 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/status_response.py", "mcstatus/forge_data.py", "mcstatus/motd/transformers.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) ================================================ FILE: tests/test_server.py ================================================ from __future__ import annotations import asyncio from typing import SupportsIndex, TYPE_CHECKING, TypeAlias from unittest.mock import call, patch import pytest import pytest_asyncio from mcstatus._net.address import Address from mcstatus._protocol.connection import BaseAsyncReadSyncWriteConnection, Connection from mcstatus.server import BedrockServer, JavaServer, LegacyServer if TYPE_CHECKING: from collections.abc import Iterable BytesConvertable: TypeAlias = "SupportsIndex | Iterable[SupportsIndex]" class AsyncConnection(BaseAsyncReadSyncWriteConnection): def __init__(self) -> None: self.sent = bytearray() self.received = bytearray() async def read(self, length: int) -> bytearray: """Return :attr:`.received` up to length bytes, then cut received up to that point.""" if len(self.received) < length: raise OSError(f"Not enough data to read! {len(self.received)} < {length}") result = self.received[:length] self.received = self.received[length:] return result def write(self, data: Connection | str | bytearray | bytes) -> None: """Extend :attr:`.sent` from ``data``.""" if isinstance(data, Connection): data = data.flush() if isinstance(data, str): data = bytearray(data, "utf-8") self.sent.extend(data) def receive(self, data: BytesConvertable | bytearray) -> None: """Extend :attr:`.received` with ``data``.""" if not isinstance(data, bytearray): data = bytearray(data) self.received.extend(data) def remaining(self) -> int: """Return length of :attr:`.received`.""" return len(self.received) def flush(self) -> bytearray: """Return :attr:`.sent`, also clears :attr:`.sent`.""" result, self.sent = self.sent, bytearray() return result class MockProtocolFactory(asyncio.Protocol): transport: asyncio.Transport def __init__(self, data_expected_to_receive, data_to_respond_with): self.data_expected_to_receive = data_expected_to_receive self.data_to_respond_with = data_to_respond_with def connection_made(self, transport: asyncio.Transport): # pyright: ignore[reportIncompatibleMethodOverride] print("connection_made") self.transport = transport def connection_lost(self, exc): print("connection_lost") self.transport.close() def data_received(self, data): assert self.data_expected_to_receive in data self.transport.write(self.data_to_respond_with) def eof_received(self): print("eof_received") def pause_writing(self): print("pause_writing") def resume_writing(self): print("resume_writing") @pytest_asyncio.fixture() async def create_mock_packet_server(): event_loop = asyncio.get_running_loop() servers = [] async def create_server(port, data_expected_to_receive, data_to_respond_with): server = await event_loop.create_server( lambda: MockProtocolFactory(data_expected_to_receive, data_to_respond_with), host="localhost", port=port, ) servers.append(server) return server yield create_server for server in servers: server.close() await server.wait_closed() class TestBedrockServer: def setup_method(self): self.server = BedrockServer("localhost") 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 class TestAsyncJavaServer: @pytest.mark.asyncio async def test_async_ping(self, unused_tcp_port, create_mock_packet_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("localhost", port=unused_tcp_port) latency = await minecraft_server.async_ping(ping_token=29704774, version=47) assert latency >= 0 @pytest.mark.asyncio 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("localhost", 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) @pytest.mark.asyncio async def test_java_server_with_query_port_async(): with patch("mcstatus.server.JavaServer._retry_async_query") as patched_query_func: server = JavaServer("localhost", 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) class TestJavaServer: def setup_method(self): self.socket = Connection() self.server = JavaServer("localhost") 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("0F002F096C6F63616C686F737463DD0109010000000001C54246") 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("0F002F096C6F63616C686F737463DD010100") 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): self.socket.receive(bytearray.fromhex("090000000035373033353037373800")) self.socket.receive( bytearray.fromhex( "00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797" "06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077" "6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737" "46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053" "746576650000" ) ) with patch("mcstatus._protocol.connection.Connection.remaining") as mock_remaining: mock_remaining.side_effect = [15, 208] with ( patch("mcstatus.server.UDPSocketConnection") as connection, patch.object(self.server.address, "resolve_ip") as resolve_ip, ): connection.return_value.__enter__.return_value = self.socket resolve_ip.return_value = "127.0.0.1" info = self.server.query() conn_bytes = self.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"^$"), patch.object(self.server.address, "resolve_ip") as resolve_ip: # noqa: PT012 resolve_ip.return_value = "127.0.0.1" 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 class TestLegacyServer: def setup_method(self): self.socket = Connection() self.server = LegacyServer("localhost") 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 class TestAsyncLegacyServer: def setup_method(self): self.socket = AsyncConnection() self.server = LegacyServer("localhost") @pytest.mark.asyncio 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 @pytest.mark.asyncio 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 ================================================ FILE: tests/utils/__init__.py ================================================ ================================================ FILE: tests/utils/test_deprecation.py ================================================ from __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: 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 ================================================ FILE: tests/utils/test_general.py ================================================ import 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, b, result): assert or_none(a, b) == result def test_or_none_many_arguments(): assert or_none(*([None] * 100 + ["value"])) == "value" ================================================ FILE: tests/utils/test_retry.py ================================================ import pytest from mcstatus._utils.retry import retry from tests.protocol.test_java_client_async 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(): 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(): 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)()