mirror of
https://github.com/Dinnerbone/mcstatus.git
synced 2026-04-05 19:41:24 +08:00
Moving to new organization
This commit is contained in:
15
.flake8
15
.flake8
@@ -1,15 +0,0 @@
|
|||||||
[flake8]
|
|
||||||
max-line-length=127
|
|
||||||
extend-ignore=E203
|
|
||||||
application-import-names=mcstatus
|
|
||||||
ban-relative-imports=true
|
|
||||||
import-order-style=pycharm
|
|
||||||
exclude=.venv,.git,.cache
|
|
||||||
ignore=
|
|
||||||
ANN001, # TEMPORARY: parameter annotation (we still have a lot of these)
|
|
||||||
ANN201, # TEMPORARY: return type annotation (we still have a lot of these)
|
|
||||||
ANN002, # *args annotation
|
|
||||||
ANN003, # **kwargs annotation
|
|
||||||
ANN101, # self param annotation
|
|
||||||
ANN102, # cls param annotation
|
|
||||||
ANN204, # return type annotation for special methods
|
|
||||||
64
.github/workflows/tox-test.yml
vendored
64
.github/workflows/tox-test.yml
vendored
@@ -1,64 +0,0 @@
|
|||||||
name: Tox test
|
|
||||||
|
|
||||||
on: [pull_request, push]
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Make sure pip caches dependencies and installs as user
|
|
||||||
PIP_NO_CACHE_DIR: false
|
|
||||||
PIP_USER: 1
|
|
||||||
|
|
||||||
# Make sure poetry won't use virtual environments
|
|
||||||
POETRY_VIRTUALENVS_CREATE: false
|
|
||||||
|
|
||||||
# Specify paths here, so we know exactly where things are for caching
|
|
||||||
PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
|
|
||||||
POETRY_CACHE_DIR: ${{ github.workspace }}/.cache/py-user-base
|
|
||||||
TOXDIR: ${{ github.workspace }}/.tox
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tox-test:
|
|
||||||
runs-on: ${{ matrix.platform }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
platform: [ubuntu-latest, macos-latest, windows-latest]
|
|
||||||
python-version: ["3.7", "3.8", "3.9", "3.10"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Python setup
|
|
||||||
id: python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
|
|
||||||
# Cache python dependencies so that unless we change them,
|
|
||||||
# we won't need to reinstall them with each workflow run.
|
|
||||||
# The key is a composite of multiple values, which when changed
|
|
||||||
# the cache won't be restored in order to make updating possible
|
|
||||||
- name: Python dependency caching
|
|
||||||
uses: actions/cache@v2
|
|
||||||
id: python_cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
${{ env.PYTHONUSERBASE }}
|
|
||||||
${{ env.TOXDIR }}
|
|
||||||
key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\
|
|
||||||
${{ env.TOXDIR }}-${{ steps.python.outputs.python-version }}-\
|
|
||||||
${{ hashFiles('./pyproject.toml', './poetry.lock') }}"
|
|
||||||
|
|
||||||
# In case the dependencies weren't restored, install them
|
|
||||||
- name: Install dependencies using poetry
|
|
||||||
if: steps.python_cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
pip install poetry
|
|
||||||
pip install tox
|
|
||||||
pip install tox-poetry
|
|
||||||
pip install tox-gh-actions
|
|
||||||
|
|
||||||
- name: Test with tox
|
|
||||||
run: python -m tox
|
|
||||||
env:
|
|
||||||
PIP_USER: 0 # We want tox to use it's environments, not user installs
|
|
||||||
63
.github/workflows/validation.yml
vendored
63
.github/workflows/validation.yml
vendored
@@ -1,63 +0,0 @@
|
|||||||
name: Validation
|
|
||||||
|
|
||||||
on: [pull_request, push]
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Make sure pip caches dependencies and installs as user
|
|
||||||
PIP_NO_CACHE_DIR: false
|
|
||||||
PIP_USER: 1
|
|
||||||
|
|
||||||
# Make sure poetry won't use virtual environments
|
|
||||||
POETRY_VIRTUALENVS_CREATE: false
|
|
||||||
|
|
||||||
# Specify paths here, so we know what to cache
|
|
||||||
POETRY_CACHE_DIR: ${{ github.workspace }}/.cache/py-user-base
|
|
||||||
PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Add custom PYTHONUSERBASE to PATH
|
|
||||||
run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Python setup
|
|
||||||
id: python
|
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: '3.9.5'
|
|
||||||
|
|
||||||
# Cache python dependencies so that unless we change them,
|
|
||||||
# we won't need to reinstall them with each workflow run.
|
|
||||||
# The key is a composite of multiple values, which when changed
|
|
||||||
# the cache won't be restored in order to make updating possible
|
|
||||||
- name: Python dependency caching
|
|
||||||
uses: actions/cache@v2
|
|
||||||
id: python_cache
|
|
||||||
with:
|
|
||||||
path: ${{ env.PYTHONUSERBASE }}
|
|
||||||
key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\
|
|
||||||
${{ steps.python.outputs.python-version }}-\
|
|
||||||
${{ hashFiles('./pyproject.toml', './poetry.lock') }}"
|
|
||||||
|
|
||||||
# In case the dependencies weren't restored, install them
|
|
||||||
- name: Install dependencies using poetry
|
|
||||||
if: steps.python_cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
pip install poetry
|
|
||||||
poetry install
|
|
||||||
|
|
||||||
# Run the actual linting steps here:
|
|
||||||
|
|
||||||
- name: Run black formatter check
|
|
||||||
run: black --check .
|
|
||||||
|
|
||||||
- name: Run pyright type checker
|
|
||||||
run: pyright -v $PYTHONUSERBASE
|
|
||||||
|
|
||||||
- name: Run flake8 linter
|
|
||||||
run: flake8 .
|
|
||||||
108
.gitignore
vendored
108
.gitignore
vendored
@@ -1,108 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.coverage*
|
|
||||||
.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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
3.9.6
|
|
||||||
3.8.10
|
|
||||||
3.7.9
|
|
||||||
3.6.8
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Setup:
|
|
||||||
```
|
|
||||||
pipx install poetry
|
|
||||||
pipx inject poetry poetry-dynamic-versioning
|
|
||||||
pipx install tox
|
|
||||||
pipx inject tox tox-poetry
|
|
||||||
```
|
|
||||||
202
LICENSE
202
LICENSE
@@ -1,202 +0,0 @@
|
|||||||
|
|
||||||
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.
|
|
||||||
104
README.md
104
README.md
@@ -1,103 +1 @@
|
|||||||

|
Moving to https://github.com/py-mine/mcstatus
|
||||||
[](https://pypi.org/project/mcstatus/)
|
|
||||||

|
|
||||||
[](https://discord.gg/C2wX7zduxC)
|
|
||||||
|
|
||||||
mcstatus
|
|
||||||
========
|
|
||||||
|
|
||||||
`mcstatus` provides an easy way to query Minecraft servers for any information they can expose.
|
|
||||||
It provides three modes of access (`query`, `status` and `ping`), the differences of which are listed below in usage.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
|
|
||||||
Java Edition
|
|
||||||
```python
|
|
||||||
from mcstatus import MinecraftServer
|
|
||||||
|
|
||||||
# If you know the host and port, you may skip this and use MinecraftServer("example.org", 1234)
|
|
||||||
server = MinecraftServer.lookup("example.org:1234")
|
|
||||||
|
|
||||||
# 'status' is supported by all Minecraft servers that are version 1.7 or higher.
|
|
||||||
status = server.status()
|
|
||||||
print(f"The server has {status.players.online} players 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 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 servers' 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)}")
|
|
||||||
```
|
|
||||||
|
|
||||||
Bedrock Edition
|
|
||||||
```python
|
|
||||||
from mcstatus import MinecraftBedrockServer
|
|
||||||
|
|
||||||
# If you know the host and port, you may skip this and use MinecraftBedrockServer("example.org", 19132)
|
|
||||||
server = MinecraftBedrockServer.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")
|
|
||||||
```
|
|
||||||
|
|
||||||
Command Line Interface
|
|
||||||
```
|
|
||||||
$ mcstatus
|
|
||||||
Usage: mcstatus [OPTIONS] ADDRESS COMMAND [ARGS]...
|
|
||||||
|
|
||||||
mcstatus provides an easy way to query Minecraft servers for any
|
|
||||||
information they can expose. It provides three modes of access: query,
|
|
||||||
status, and ping.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
$ mcstatus example.org ping
|
|
||||||
21.120ms
|
|
||||||
|
|
||||||
$ mcstatus example.org:1234 ping
|
|
||||||
159.903ms
|
|
||||||
|
|
||||||
$ mcstatus example.org status
|
|
||||||
version: v1.8.8 (protocol 47)
|
|
||||||
description: "A Minecraft Server"
|
|
||||||
players: 1/20 ['Dinnerbone (61699b2e-d327-4a01-9f1e-0ea8c3f06bc6)']
|
|
||||||
|
|
||||||
$ mcstatus example.org query
|
|
||||||
host: 93.148.216.34:25565
|
|
||||||
software: v1.8.8 vanilla
|
|
||||||
plugins: []
|
|
||||||
motd: "A Minecraft Server"
|
|
||||||
players: 1/20 ['Dinnerbone (61699b2e-d327-4a01-9f1e-0ea8c3f06bc6)']
|
|
||||||
|
|
||||||
Options:
|
|
||||||
-h, --help Show this message and exit.
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
json combination of several other commands with json formatting
|
|
||||||
ping prints server latency
|
|
||||||
query detailed server information
|
|
||||||
status basic server information
|
|
||||||
```
|
|
||||||
|
|
||||||
Installation
|
|
||||||
------------
|
|
||||||
|
|
||||||
mcstatus is available on pypi, and can be installed trivially with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m pip install mcstatus
|
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, just clone this repo!
|
|
||||||
|
|
||||||
License
|
|
||||||
-------
|
|
||||||
|
|
||||||
mcstatus is licensed under Apache 2.0.
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from mcstatus.server import MinecraftBedrockServer, MinecraftServer # noqa: F401
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
from time import perf_counter
|
|
||||||
|
|
||||||
import asyncio_dgram
|
|
||||||
|
|
||||||
|
|
||||||
class BedrockServerStatus:
|
|
||||||
request_status_data = b"\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\xfe\xfe\xfe\xfe\xfd\xfd\xfd\xfd\x124Vx"
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int = 19132, timeout: float = 3):
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
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(";")
|
|
||||||
|
|
||||||
try:
|
|
||||||
map_ = decoded_data[7]
|
|
||||||
except IndexError:
|
|
||||||
map_ = None
|
|
||||||
try:
|
|
||||||
gamemode = decoded_data[8]
|
|
||||||
except IndexError:
|
|
||||||
gamemode = None
|
|
||||||
|
|
||||||
return BedrockStatusResponse(
|
|
||||||
protocol=decoded_data[2],
|
|
||||||
brand=decoded_data[0],
|
|
||||||
version=decoded_data[3],
|
|
||||||
latency=latency,
|
|
||||||
players_online=decoded_data[4],
|
|
||||||
players_max=decoded_data[5],
|
|
||||||
motd=decoded_data[1],
|
|
||||||
map_=map_,
|
|
||||||
gamemode=gamemode,
|
|
||||||
)
|
|
||||||
|
|
||||||
def read_status(self) -> BedrockStatusResponse:
|
|
||||||
start = perf_counter()
|
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
s.settimeout(self.timeout)
|
|
||||||
|
|
||||||
s.sendto(self.request_status_data, (self.host, self.port))
|
|
||||||
data, _ = s.recvfrom(2048)
|
|
||||||
|
|
||||||
return self.parse_response(data, (perf_counter() - start))
|
|
||||||
|
|
||||||
async def read_status_async(self) -> BedrockStatusResponse:
|
|
||||||
start = perf_counter()
|
|
||||||
stream = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = asyncio_dgram.connect((self.host, self.port))
|
|
||||||
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 self.parse_response(data, (perf_counter() - start))
|
|
||||||
|
|
||||||
|
|
||||||
class BedrockStatusResponse:
|
|
||||||
class Version:
|
|
||||||
def __init__(self, protocol, brand, version):
|
|
||||||
self.protocol = protocol
|
|
||||||
self.brand = brand
|
|
||||||
self.version = version
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
protocol,
|
|
||||||
brand,
|
|
||||||
version,
|
|
||||||
latency,
|
|
||||||
players_online,
|
|
||||||
players_max,
|
|
||||||
motd,
|
|
||||||
map_,
|
|
||||||
gamemode,
|
|
||||||
):
|
|
||||||
self.version = self.Version(protocol, brand, version)
|
|
||||||
self.latency = latency
|
|
||||||
self.players_online = players_online
|
|
||||||
self.players_max = players_max
|
|
||||||
self.motd = motd
|
|
||||||
self.map = map_
|
|
||||||
self.gamemode = gamemode
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
from typing import List, Optional, Union
|
|
||||||
|
|
||||||
from mcstatus.protocol.connection import Connection, TCPAsyncSocketConnection, TCPSocketConnection
|
|
||||||
|
|
||||||
STYLE_MAP = {
|
|
||||||
"bold": "l",
|
|
||||||
"italic": "o",
|
|
||||||
"underlined": "n",
|
|
||||||
"obfuscated": "k",
|
|
||||||
"color": {
|
|
||||||
"dark_red": "4",
|
|
||||||
"red": "c",
|
|
||||||
"gold": "6",
|
|
||||||
"yellow": "e",
|
|
||||||
"dark_green": "2",
|
|
||||||
"green": "a",
|
|
||||||
"aqua": "b",
|
|
||||||
"dark_aqua": "3",
|
|
||||||
"dark_blue": "1",
|
|
||||||
"blue": "9",
|
|
||||||
"light_purple": "d",
|
|
||||||
"dark_purple": "5",
|
|
||||||
"white": "f",
|
|
||||||
"gray": "7",
|
|
||||||
"dark_gray": "8",
|
|
||||||
"black": "0",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ServerPinger:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
connection: TCPSocketConnection,
|
|
||||||
host: str = "",
|
|
||||||
port: int = 0,
|
|
||||||
version: int = 47,
|
|
||||||
ping_token=None,
|
|
||||||
):
|
|
||||||
if ping_token is None:
|
|
||||||
ping_token = random.randint(0, (1 << 63) - 1)
|
|
||||||
self.version = version
|
|
||||||
self.connection = connection
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.ping_token = ping_token
|
|
||||||
|
|
||||||
def handshake(self) -> None:
|
|
||||||
packet = Connection()
|
|
||||||
packet.write_varint(0)
|
|
||||||
packet.write_varint(self.version)
|
|
||||||
packet.write_utf(self.host)
|
|
||||||
packet.write_ushort(self.port)
|
|
||||||
packet.write_varint(1) # Intention to query status
|
|
||||||
|
|
||||||
self.connection.write_buffer(packet)
|
|
||||||
|
|
||||||
def read_status(self) -> "PingResponse":
|
|
||||||
request = Connection()
|
|
||||||
request.write_varint(0) # Request status
|
|
||||||
self.connection.write_buffer(request)
|
|
||||||
|
|
||||||
response = self.connection.read_buffer()
|
|
||||||
if response.read_varint() != 0:
|
|
||||||
raise IOError("Received invalid status response packet.")
|
|
||||||
try:
|
|
||||||
raw = json.loads(response.read_utf())
|
|
||||||
except ValueError:
|
|
||||||
raise IOError("Received invalid JSON")
|
|
||||||
try:
|
|
||||||
return PingResponse(raw)
|
|
||||||
except ValueError as e:
|
|
||||||
raise IOError(f"Received invalid status response: {e}")
|
|
||||||
|
|
||||||
def test_ping(self) -> float:
|
|
||||||
request = Connection()
|
|
||||||
request.write_varint(1) # Test ping
|
|
||||||
request.write_long(self.ping_token)
|
|
||||||
sent = datetime.datetime.now()
|
|
||||||
self.connection.write_buffer(request)
|
|
||||||
|
|
||||||
response = self.connection.read_buffer()
|
|
||||||
received = datetime.datetime.now()
|
|
||||||
if response.read_varint() != 1:
|
|
||||||
raise IOError("Received invalid ping response packet.")
|
|
||||||
received_token = response.read_long()
|
|
||||||
if received_token != self.ping_token:
|
|
||||||
raise IOError(
|
|
||||||
f"Received mangled ping response packet (expected token {self.ping_token}, received {received_token})"
|
|
||||||
)
|
|
||||||
|
|
||||||
delta = received - sent
|
|
||||||
return delta.total_seconds() * 1000
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncServerPinger(ServerPinger):
|
|
||||||
def __init__(
|
|
||||||
self, connection: TCPAsyncSocketConnection, host: str = "", port: int = 0, version: int = 47, ping_token=None
|
|
||||||
):
|
|
||||||
# We do this to inform python about self.connection type (it's async)
|
|
||||||
super().__init__(connection, host=host, port=port, version=version, ping_token=ping_token) # type: ignore[arg-type]
|
|
||||||
self.connection: TCPAsyncSocketConnection
|
|
||||||
|
|
||||||
async def read_status(self) -> "PingResponse":
|
|
||||||
request = Connection()
|
|
||||||
request.write_varint(0) # Request status
|
|
||||||
self.connection.write_buffer(request)
|
|
||||||
|
|
||||||
response = await self.connection.read_buffer()
|
|
||||||
if response.read_varint() != 0:
|
|
||||||
raise IOError("Received invalid status response packet.")
|
|
||||||
try:
|
|
||||||
raw = json.loads(response.read_utf())
|
|
||||||
except ValueError:
|
|
||||||
raise IOError("Received invalid JSON")
|
|
||||||
try:
|
|
||||||
return PingResponse(raw)
|
|
||||||
except ValueError as e:
|
|
||||||
raise IOError(f"Received invalid status response: {e}")
|
|
||||||
|
|
||||||
async def test_ping(self) -> float:
|
|
||||||
request = Connection()
|
|
||||||
request.write_varint(1) # Test ping
|
|
||||||
request.write_long(self.ping_token)
|
|
||||||
sent = datetime.datetime.now()
|
|
||||||
self.connection.write_buffer(request)
|
|
||||||
|
|
||||||
response = await self.connection.read_buffer()
|
|
||||||
received = datetime.datetime.now()
|
|
||||||
if response.read_varint() != 1:
|
|
||||||
raise IOError("Received invalid ping response packet.")
|
|
||||||
received_token = response.read_long()
|
|
||||||
if received_token != self.ping_token:
|
|
||||||
raise IOError(
|
|
||||||
f"Received mangled ping response packet (expected token {self.ping_token}, received {received_token})"
|
|
||||||
)
|
|
||||||
|
|
||||||
delta = received - sent
|
|
||||||
return delta.total_seconds() * 1000
|
|
||||||
|
|
||||||
|
|
||||||
class PingResponse:
|
|
||||||
# THIS IS SO UNPYTHONIC
|
|
||||||
# it's staying just because the tests depend on this structure
|
|
||||||
class Players:
|
|
||||||
class Player:
|
|
||||||
name: str
|
|
||||||
id: str
|
|
||||||
|
|
||||||
def __init__(self, raw):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Invalid player object (expected dict, found {type(raw)}")
|
|
||||||
|
|
||||||
if "name" not in raw:
|
|
||||||
raise ValueError("Invalid player object (no 'name' value)")
|
|
||||||
if not isinstance(raw["name"], str):
|
|
||||||
raise ValueError(f"Invalid player object (expected 'name' to be str, was {type(raw['name'])}")
|
|
||||||
self.name = raw["name"]
|
|
||||||
|
|
||||||
if "id" not in raw:
|
|
||||||
raise ValueError("Invalid player object (no 'id' value)")
|
|
||||||
if not isinstance(raw["id"], str):
|
|
||||||
raise ValueError(f"Invalid player object (expected 'id' to be str, was {type(raw['id'])}")
|
|
||||||
self.id = raw["id"]
|
|
||||||
|
|
||||||
online: int
|
|
||||||
max: int
|
|
||||||
sample: Optional[List["PingResponse.Players.Player"]]
|
|
||||||
|
|
||||||
def __init__(self, raw):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Invalid players object (expected dict, found {type(raw)}")
|
|
||||||
|
|
||||||
if "online" not in raw:
|
|
||||||
raise ValueError("Invalid players object (no 'online' value)")
|
|
||||||
if not isinstance(raw["online"], int):
|
|
||||||
raise ValueError(f"Invalid players object (expected 'online' to be int, was {type(raw['online'])})")
|
|
||||||
self.online = raw["online"]
|
|
||||||
|
|
||||||
if "max" not in raw:
|
|
||||||
raise ValueError("Invalid players object (no 'max' value)")
|
|
||||||
if not isinstance(raw["max"], int):
|
|
||||||
raise ValueError(f"Invalid players object (expected 'max' to be int, was {type(raw['max'])}")
|
|
||||||
self.max = raw["max"]
|
|
||||||
|
|
||||||
if "sample" in raw:
|
|
||||||
if not isinstance(raw["sample"], list):
|
|
||||||
raise ValueError(f"Invalid players object (expected 'sample' to be list, was {type(raw['max'])})")
|
|
||||||
self.sample = [PingResponse.Players.Player(p) for p in raw["sample"]]
|
|
||||||
else:
|
|
||||||
self.sample = None
|
|
||||||
|
|
||||||
class Version:
|
|
||||||
name: str
|
|
||||||
protocol: int
|
|
||||||
|
|
||||||
def __init__(self, raw):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raise ValueError(f"Invalid version object (expected dict, found {type(raw)})")
|
|
||||||
|
|
||||||
if "name" not in raw:
|
|
||||||
raise ValueError("Invalid version object (no 'name' value)")
|
|
||||||
if not isinstance(raw["name"], str):
|
|
||||||
raise ValueError(f"Invalid version object (expected 'name' to be str, was {type(raw['name'])})")
|
|
||||||
self.name = raw["name"]
|
|
||||||
|
|
||||||
if "protocol" not in raw:
|
|
||||||
raise ValueError("Invalid version object (no 'protocol' value)")
|
|
||||||
if not isinstance(raw["protocol"], int):
|
|
||||||
raise ValueError(f"Invalid version object (expected 'protocol' to be int, was {type(raw['protocol'])})")
|
|
||||||
self.protocol = raw["protocol"]
|
|
||||||
|
|
||||||
players: Players
|
|
||||||
version: Version
|
|
||||||
description: str
|
|
||||||
favicon: Optional[str]
|
|
||||||
latency: float = 0
|
|
||||||
|
|
||||||
def __init__(self, raw):
|
|
||||||
self.raw = raw
|
|
||||||
|
|
||||||
if "players" not in raw:
|
|
||||||
raise ValueError("Invalid status object (no 'players' value)")
|
|
||||||
self.players = PingResponse.Players(raw["players"])
|
|
||||||
|
|
||||||
if "version" not in raw:
|
|
||||||
raise ValueError("Invalid status object (no 'version' value)")
|
|
||||||
self.version = PingResponse.Version(raw["version"])
|
|
||||||
|
|
||||||
if "description" not in raw:
|
|
||||||
raise ValueError("Invalid status object (no 'description' value)")
|
|
||||||
self.description = self._parse_description(raw["description"])
|
|
||||||
|
|
||||||
self.favicon = raw.get("favicon")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_description(raw_description: Union[dict, list, str]) -> str:
|
|
||||||
if isinstance(raw_description, str):
|
|
||||||
return raw_description
|
|
||||||
|
|
||||||
if isinstance(raw_description, dict):
|
|
||||||
entries = raw_description.get("extra", [])
|
|
||||||
end = raw_description["text"]
|
|
||||||
else:
|
|
||||||
entries = raw_description
|
|
||||||
end = ""
|
|
||||||
|
|
||||||
description = ""
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
for style_key, style_val in STYLE_MAP.items():
|
|
||||||
if entry.get(style_key):
|
|
||||||
try:
|
|
||||||
if isinstance(style_val, dict):
|
|
||||||
style_val = style_val[entry[style_key]]
|
|
||||||
|
|
||||||
description += f"§{style_val}"
|
|
||||||
except KeyError:
|
|
||||||
pass # ignoring these key errors strips out html color codes
|
|
||||||
description += entry.get("text", "")
|
|
||||||
|
|
||||||
return description + end
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from ctypes import c_int32 as signed_int32
|
|
||||||
from ctypes import c_uint32 as unsigned_int32
|
|
||||||
from ipaddress import ip_address
|
|
||||||
from typing import Iterable, Optional, SupportsBytes, TYPE_CHECKING, Tuple, Union
|
|
||||||
|
|
||||||
import asyncio_dgram
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing_extensions import SupportsIndex # Python 3.7 doesn't support this yet.
|
|
||||||
|
|
||||||
BytesConvertable = Union[SupportsIndex, Iterable[SupportsIndex]]
|
|
||||||
|
|
||||||
|
|
||||||
def ip_type(address: Union[int, str]) -> Optional[int]:
|
|
||||||
try:
|
|
||||||
return ip_address(address).version
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
|
||||||
def __init__(self):
|
|
||||||
self.sent = bytearray()
|
|
||||||
self.received = bytearray()
|
|
||||||
|
|
||||||
def read(self, length: int) -> bytearray:
|
|
||||||
result = self.received[:length]
|
|
||||||
self.received = self.received[length:]
|
|
||||||
return result
|
|
||||||
|
|
||||||
def write(self, data: Union["Connection", str, bytearray, bytes]) -> None:
|
|
||||||
if isinstance(data, Connection):
|
|
||||||
data = data.flush()
|
|
||||||
if isinstance(data, str):
|
|
||||||
data = bytearray(data, "utf-8")
|
|
||||||
self.sent.extend(data)
|
|
||||||
|
|
||||||
def receive(self, data: Union[BytesConvertable, bytearray]) -> None:
|
|
||||||
if not isinstance(data, bytearray):
|
|
||||||
data = bytearray(data)
|
|
||||||
self.received.extend(data)
|
|
||||||
|
|
||||||
def remaining(self) -> int:
|
|
||||||
return len(self.received)
|
|
||||||
|
|
||||||
def flush(self) -> bytearray:
|
|
||||||
result = self.sent
|
|
||||||
self.sent = bytearray()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _unpack(self, format: str, data: Union[BytesConvertable, SupportsBytes]) -> int:
|
|
||||||
return struct.unpack(">" + format, bytes(data))[0]
|
|
||||||
|
|
||||||
def _pack(self, format: str, data: int) -> bytes:
|
|
||||||
return struct.pack(">" + format, data)
|
|
||||||
|
|
||||||
def read_varint(self) -> int:
|
|
||||||
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 IOError("Server sent a varint that was too big!")
|
|
||||||
|
|
||||||
def write_varint(self, value: int) -> None:
|
|
||||||
if value < -(2**31) or 2**31 - 1 < value:
|
|
||||||
raise ValueError("Minecraft varints must be in the range of [-2**31, 2**31 - 1].")
|
|
||||||
remaining = unsigned_int32(value).value
|
|
||||||
for _ in range(5):
|
|
||||||
if remaining & ~0x7F == 0:
|
|
||||||
self.write(struct.pack("!B", remaining))
|
|
||||||
return
|
|
||||||
self.write(struct.pack("!B", remaining & 0x7F | 0x80))
|
|
||||||
remaining >>= 7
|
|
||||||
|
|
||||||
def read_utf(self) -> str:
|
|
||||||
length = self.read_varint()
|
|
||||||
return self.read(length).decode("utf8")
|
|
||||||
|
|
||||||
def write_utf(self, value: str) -> None:
|
|
||||||
self.write_varint(len(value))
|
|
||||||
self.write(bytearray(value, "utf8"))
|
|
||||||
|
|
||||||
def read_ascii(self) -> str:
|
|
||||||
result = bytearray()
|
|
||||||
while len(result) == 0 or result[-1] != 0:
|
|
||||||
result.extend(self.read(1))
|
|
||||||
return result[:-1].decode("ISO-8859-1")
|
|
||||||
|
|
||||||
def write_ascii(self, value: str) -> None:
|
|
||||||
self.write(bytearray(value, "ISO-8859-1"))
|
|
||||||
self.write(bytearray.fromhex("00"))
|
|
||||||
|
|
||||||
def read_short(self) -> int:
|
|
||||||
return self._unpack("h", self.read(2))
|
|
||||||
|
|
||||||
def write_short(self, value: int) -> None:
|
|
||||||
self.write(self._pack("h", value))
|
|
||||||
|
|
||||||
def read_ushort(self) -> int:
|
|
||||||
return self._unpack("H", self.read(2))
|
|
||||||
|
|
||||||
def write_ushort(self, value: int) -> None:
|
|
||||||
self.write(self._pack("H", value))
|
|
||||||
|
|
||||||
def read_int(self) -> int:
|
|
||||||
return self._unpack("i", self.read(4))
|
|
||||||
|
|
||||||
def write_int(self, value: int) -> None:
|
|
||||||
self.write(self._pack("i", value))
|
|
||||||
|
|
||||||
def read_uint(self) -> int:
|
|
||||||
return self._unpack("I", self.read(4))
|
|
||||||
|
|
||||||
def write_uint(self, value: int) -> None:
|
|
||||||
self.write(self._pack("I", value))
|
|
||||||
|
|
||||||
def read_long(self) -> int:
|
|
||||||
return self._unpack("q", self.read(8))
|
|
||||||
|
|
||||||
def write_long(self, value: int) -> None:
|
|
||||||
self.write(self._pack("q", value))
|
|
||||||
|
|
||||||
def read_ulong(self) -> int:
|
|
||||||
return self._unpack("Q", self.read(8))
|
|
||||||
|
|
||||||
def write_ulong(self, value: int) -> None:
|
|
||||||
self.write(self._pack("Q", value))
|
|
||||||
|
|
||||||
def read_buffer(self) -> "Connection":
|
|
||||||
length = self.read_varint()
|
|
||||||
result = Connection()
|
|
||||||
result.receive(self.read(length))
|
|
||||||
return result
|
|
||||||
|
|
||||||
def write_buffer(self, buffer: "Connection") -> None:
|
|
||||||
data = buffer.flush()
|
|
||||||
self.write_varint(len(data))
|
|
||||||
self.write(data)
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncReadConnection(Connection, ABC):
|
|
||||||
@abstractmethod
|
|
||||||
async def read(self, length: int) -> bytearray:
|
|
||||||
...
|
|
||||||
|
|
||||||
async def read_varint(self) -> int:
|
|
||||||
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 IOError("Server sent a varint that was too big!")
|
|
||||||
|
|
||||||
async def read_utf(self) -> str:
|
|
||||||
length = await self.read_varint()
|
|
||||||
return (await self.read(length)).decode("utf8")
|
|
||||||
|
|
||||||
async def read_ascii(self) -> str:
|
|
||||||
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 self._unpack("h", await self.read(2))
|
|
||||||
|
|
||||||
async def read_ushort(self) -> int:
|
|
||||||
return self._unpack("H", await self.read(2))
|
|
||||||
|
|
||||||
async def read_int(self) -> int:
|
|
||||||
return self._unpack("i", await self.read(4))
|
|
||||||
|
|
||||||
async def read_uint(self) -> int:
|
|
||||||
return self._unpack("I", await self.read(4))
|
|
||||||
|
|
||||||
async def read_long(self) -> int:
|
|
||||||
return self._unpack("q", await self.read(8))
|
|
||||||
|
|
||||||
async def read_ulong(self) -> int:
|
|
||||||
return self._unpack("Q", await self.read(8))
|
|
||||||
|
|
||||||
async def read_buffer(self) -> Connection:
|
|
||||||
length = await self.read_varint()
|
|
||||||
result = Connection()
|
|
||||||
result.receive(await self.read(length))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
class TCPSocketConnection(Connection):
|
|
||||||
def __init__(self, addr: Tuple[str, int], timeout: float = 3):
|
|
||||||
Connection.__init__(self)
|
|
||||||
self.socket = socket.create_connection(addr, timeout=timeout)
|
|
||||||
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
||||||
|
|
||||||
def flush(self) -> bytearray:
|
|
||||||
raise NotImplementedError("TCPSocketConnection does not support flush()")
|
|
||||||
|
|
||||||
def receive(self, data: Union[BytesConvertable, SupportsBytes]) -> None:
|
|
||||||
raise NotImplementedError("TCPSocketConnection does not support receive()")
|
|
||||||
|
|
||||||
def remaining(self) -> int:
|
|
||||||
raise NotImplementedError("TCPSocketConnection does not support remaining()")
|
|
||||||
|
|
||||||
def read(self, length: int) -> bytearray:
|
|
||||||
result = bytearray()
|
|
||||||
while len(result) < length:
|
|
||||||
new = self.socket.recv(length - len(result))
|
|
||||||
if len(new) == 0:
|
|
||||||
raise IOError("Server did not respond with any information!")
|
|
||||||
result.extend(new)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def write(self, data: Union[bytes, bytearray]) -> None:
|
|
||||||
self.socket.send(data)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
self.socket.close()
|
|
||||||
except Exception: # TODO: Check what this actually excepts
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UDPSocketConnection(Connection):
|
|
||||||
def __init__(self, addr: Tuple[str, int], timeout: float = 3):
|
|
||||||
Connection.__init__(self)
|
|
||||||
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 flush(self) -> bytearray:
|
|
||||||
raise NotImplementedError("UDPSocketConnection does not support flush()")
|
|
||||||
|
|
||||||
def receive(self, data: Union[BytesConvertable, SupportsBytes]) -> None:
|
|
||||||
raise NotImplementedError("UDPSocketConnection does not support receive()")
|
|
||||||
|
|
||||||
def remaining(self) -> int:
|
|
||||||
return 65535
|
|
||||||
|
|
||||||
def read(self, length: int) -> bytearray:
|
|
||||||
result = bytearray()
|
|
||||||
while len(result) == 0:
|
|
||||||
result.extend(self.socket.recvfrom(self.remaining())[0])
|
|
||||||
return result
|
|
||||||
|
|
||||||
def write(self, data: Union[Connection, bytes, bytearray]) -> None:
|
|
||||||
if isinstance(data, Connection):
|
|
||||||
data = bytearray(data.flush())
|
|
||||||
self.socket.sendto(data, self.addr)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
self.socket.close()
|
|
||||||
except Exception: # TODO: Check what this actually excepts
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TCPAsyncSocketConnection(AsyncReadConnection):
|
|
||||||
# These will only be None until connect is called, ignore the None type assignment
|
|
||||||
reader: asyncio.StreamReader = None # type: ignore[assignment]
|
|
||||||
writer: asyncio.StreamWriter = None # type: ignore[assignment]
|
|
||||||
timeout: float = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
async def connect(self, addr: Tuple[str, int], timeout: float = 3):
|
|
||||||
self.timeout = timeout
|
|
||||||
conn = asyncio.open_connection(addr[0], addr[1])
|
|
||||||
self.reader, self.writer = await asyncio.wait_for(conn, timeout=self.timeout)
|
|
||||||
|
|
||||||
async def read(self, length: int) -> bytearray:
|
|
||||||
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 IOError("Server did not respond with any information!")
|
|
||||||
result.extend(new)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def write(self, data: Union[bytes, bytearray]) -> None:
|
|
||||||
self.writer.write(data)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
self.writer.close()
|
|
||||||
except Exception: # TODO: Check what this actually expects
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class UDPAsyncSocketConnection(AsyncReadConnection):
|
|
||||||
# These will only be None until connect is called, ignore the None type assignment
|
|
||||||
stream: asyncio_dgram.aio.DatagramClient = None # type: ignore[assignment]
|
|
||||||
timeout: float = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
async def connect(self, addr: Tuple[str, int], timeout: float = 3):
|
|
||||||
self.timeout = timeout
|
|
||||||
conn = asyncio_dgram.connect((addr[0], addr[1]))
|
|
||||||
self.stream = await asyncio.wait_for(conn, timeout=self.timeout)
|
|
||||||
|
|
||||||
def flush(self) -> bytearray:
|
|
||||||
raise NotImplementedError("UDPSocketConnection does not support flush()")
|
|
||||||
|
|
||||||
def receive(self, data: Union[SupportsBytes, BytesConvertable]) -> None:
|
|
||||||
raise NotImplementedError("UDPSocketConnection does not support receive()")
|
|
||||||
|
|
||||||
def remaining(self) -> int:
|
|
||||||
return 65535
|
|
||||||
|
|
||||||
async def read(self, length: int) -> bytes:
|
|
||||||
data, remote_addr = await asyncio.wait_for(self.stream.recv(), timeout=self.timeout)
|
|
||||||
return data
|
|
||||||
|
|
||||||
async def write(self, data: Union[Connection, bytes, bytearray]) -> None:
|
|
||||||
if isinstance(data, Connection):
|
|
||||||
data = bytearray(data.flush())
|
|
||||||
await self.stream.send(data)
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
self.stream.close()
|
|
||||||
except Exception: # TODO: Check what this actually excepts
|
|
||||||
pass
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
import re
|
|
||||||
import struct
|
|
||||||
from typing import List, TYPE_CHECKING
|
|
||||||
|
|
||||||
from mcstatus.protocol.connection import Connection, UDPAsyncSocketConnection, UDPSocketConnection
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
|
|
||||||
class ServerQuerier:
|
|
||||||
MAGIC_PREFIX = bytearray.fromhex("FEFD")
|
|
||||||
PADDING = bytearray.fromhex("00000000")
|
|
||||||
PACKET_TYPE_CHALLENGE = 9
|
|
||||||
PACKET_TYPE_QUERY = 0
|
|
||||||
|
|
||||||
def __init__(self, connection: UDPSocketConnection):
|
|
||||||
self.connection = connection
|
|
||||||
self.challenge = 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
|
|
||||||
|
|
||||||
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.from_connection(response)
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncServerQuerier(ServerQuerier):
|
|
||||||
def __init__(self, connection: UDPAsyncSocketConnection):
|
|
||||||
# We do this to inform python about self.connection type (it's async)
|
|
||||||
super().__init__(connection) # type: ignore[arg-type]
|
|
||||||
self.connection: UDPAsyncSocketConnection
|
|
||||||
|
|
||||||
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.from_connection(response)
|
|
||||||
|
|
||||||
|
|
||||||
class QueryResponse:
|
|
||||||
# THIS IS SO UNPYTHONIC
|
|
||||||
# it's staying just because the tests depend on this structure
|
|
||||||
class Players:
|
|
||||||
online: int
|
|
||||||
max: int
|
|
||||||
names: List[str]
|
|
||||||
|
|
||||||
def __init__(self, online, max, names):
|
|
||||||
self.online = int(online)
|
|
||||||
self.max = int(max)
|
|
||||||
self.names = names
|
|
||||||
|
|
||||||
class Software:
|
|
||||||
version: str
|
|
||||||
brand: str
|
|
||||||
plugins: List[str]
|
|
||||||
|
|
||||||
def __init__(self, version, plugins):
|
|
||||||
self.version = version
|
|
||||||
self.brand = "vanilla"
|
|
||||||
self.plugins = []
|
|
||||||
|
|
||||||
if plugins:
|
|
||||||
parts = plugins.split(":", 1)
|
|
||||||
self.brand = parts[0].strip()
|
|
||||||
|
|
||||||
if len(parts) == 2:
|
|
||||||
self.plugins = [s.strip() for s in parts[1].split(";")]
|
|
||||||
|
|
||||||
motd: str
|
|
||||||
map: str
|
|
||||||
players: Players
|
|
||||||
software: Software
|
|
||||||
|
|
||||||
def __init__(self, raw, players):
|
|
||||||
try:
|
|
||||||
self.raw = raw
|
|
||||||
self.motd = raw["hostname"]
|
|
||||||
self.map = raw["map"]
|
|
||||||
self.players = QueryResponse.Players(raw["numplayers"], raw["maxplayers"], players)
|
|
||||||
self.software = QueryResponse.Software(raw["version"], raw["plugins"])
|
|
||||||
except KeyError:
|
|
||||||
raise ValueError("The provided data is not valid")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_connection(cls, response: Connection) -> Self:
|
|
||||||
response.read(len("splitnum") + 1 + 1 + 1)
|
|
||||||
data = {}
|
|
||||||
players = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
key = response.read_ascii()
|
|
||||||
if key == "hostname": # hostname is actually motd in the query protocol
|
|
||||||
match = re.search(b"(.*)\x00gametype", 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_") + 1 + 1)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
motd = response.read_ascii()
|
|
||||||
if len(motd) == 0:
|
|
||||||
break
|
|
||||||
players.append(motd)
|
|
||||||
|
|
||||||
return cls(data, players)
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import socket
|
|
||||||
from json import dumps as json_dumps
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from mcstatus import MinecraftServer
|
|
||||||
|
|
||||||
server: MinecraftServer = None # type: ignore[assignment] # This will be set with cli function
|
|
||||||
|
|
||||||
|
|
||||||
@click.group(context_settings=dict(help_option_names=["-h", "--help"]))
|
|
||||||
@click.argument("address")
|
|
||||||
def cli(address):
|
|
||||||
"""
|
|
||||||
mcstatus provides an easy way to query Minecraft servers for
|
|
||||||
any information they can expose. It provides three modes of
|
|
||||||
access: query, status, and ping.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
\b
|
|
||||||
$ mcstatus example.org ping
|
|
||||||
21.120ms
|
|
||||||
|
|
||||||
\b
|
|
||||||
$ mcstatus example.org:1234 ping
|
|
||||||
159.903ms
|
|
||||||
|
|
||||||
\b
|
|
||||||
$ mcstatus example.org status
|
|
||||||
version: v1.8.8 (protocol 47)
|
|
||||||
description: "A Minecraft Server"
|
|
||||||
players: 1/20 ['Dinnerbone (61699b2e-d327-4a01-9f1e-0ea8c3f06bc6)']
|
|
||||||
|
|
||||||
\b
|
|
||||||
$ mcstatus example.org query
|
|
||||||
host: 93.148.216.34:25565
|
|
||||||
software: v1.8.8 vanilla
|
|
||||||
plugins: []
|
|
||||||
motd: "A Minecraft Server"
|
|
||||||
players: 1/20 ['Dinnerbone (61699b2e-d327-4a01-9f1e-0ea8c3f06bc6)']
|
|
||||||
"""
|
|
||||||
global server
|
|
||||||
server = MinecraftServer.lookup(address)
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help="prints server latency")
|
|
||||||
def ping():
|
|
||||||
"""
|
|
||||||
Ping server for latency.
|
|
||||||
"""
|
|
||||||
click.echo(f"{server.ping()}ms")
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help="basic server information")
|
|
||||||
def status():
|
|
||||||
"""
|
|
||||||
Prints server status. Supported by all Minecraft
|
|
||||||
servers that are version 1.7 or higher.
|
|
||||||
"""
|
|
||||||
response = server.status()
|
|
||||||
if response.players.sample is not None:
|
|
||||||
player_sample = str([f"{player.name} ({player.id})" for player in response.players.sample])
|
|
||||||
else:
|
|
||||||
player_sample = "No players online"
|
|
||||||
|
|
||||||
click.echo(f"version: v{response.version.name} (protocol {response.version.protocol})")
|
|
||||||
click.echo(f'description: "{response.description}"')
|
|
||||||
click.echo(f"players: {response.players.online}/{response.players.max} {player_sample}")
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help="all available server information in json")
|
|
||||||
def json():
|
|
||||||
"""
|
|
||||||
Prints server status and query in json. Supported by all Minecraft
|
|
||||||
servers that are version 1.7 or higher.
|
|
||||||
"""
|
|
||||||
data = {}
|
|
||||||
data["online"] = False
|
|
||||||
# Build data with responses and quit on exception
|
|
||||||
try:
|
|
||||||
ping_res = server.ping()
|
|
||||||
data["online"] = True
|
|
||||||
data["ping"] = ping_res
|
|
||||||
|
|
||||||
status_res = server.status(tries=1)
|
|
||||||
data["version"] = status_res.version.name
|
|
||||||
data["protocol"] = status_res.version.protocol
|
|
||||||
data["motd"] = status_res.description
|
|
||||||
data["player_count"] = status_res.players.online
|
|
||||||
data["player_max"] = status_res.players.max
|
|
||||||
data["players"] = []
|
|
||||||
if status_res.players.sample is not None:
|
|
||||||
data["players"] = [{"name": player.name, "id": player.id} for player in status_res.players.sample]
|
|
||||||
|
|
||||||
query_res = server.query(tries=1) # type: ignore[call-arg] # tries is supported with retry decorator
|
|
||||||
data["host_ip"] = query_res.raw["hostip"]
|
|
||||||
data["host_port"] = query_res.raw["hostport"]
|
|
||||||
data["map"] = query_res.map
|
|
||||||
data["plugins"] = query_res.software.plugins
|
|
||||||
except Exception: # TODO: Check what this actually excepts
|
|
||||||
pass
|
|
||||||
click.echo(json_dumps(data))
|
|
||||||
|
|
||||||
|
|
||||||
@cli.command(short_help="detailed server information")
|
|
||||||
def query():
|
|
||||||
"""
|
|
||||||
Prints detailed server information. Must be enabled in
|
|
||||||
servers' server.properties file.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = server.query()
|
|
||||||
except socket.timeout:
|
|
||||||
print(
|
|
||||||
"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://wiki.vg/Query for further information."
|
|
||||||
)
|
|
||||||
raise click.Abort()
|
|
||||||
click.echo(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
|
|
||||||
click.echo(f"software: v{response.software.version} {response.software.brand}")
|
|
||||||
click.echo(f"plugins: {response.software.plugins}")
|
|
||||||
click.echo(f'motd: "{response.motd}"')
|
|
||||||
click.echo(f"players: {response.players.online}/{response.players.max} {response.players.names}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
cli() # type: ignore[call-arg]
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Optional, TYPE_CHECKING, Tuple
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import dns.resolver
|
|
||||||
from dns.exception import DNSException
|
|
||||||
|
|
||||||
from mcstatus.bedrock_status import BedrockServerStatus, BedrockStatusResponse
|
|
||||||
from mcstatus.pinger import AsyncServerPinger, PingResponse, ServerPinger
|
|
||||||
from mcstatus.protocol.connection import (
|
|
||||||
TCPAsyncSocketConnection,
|
|
||||||
TCPSocketConnection,
|
|
||||||
UDPAsyncSocketConnection,
|
|
||||||
UDPSocketConnection,
|
|
||||||
)
|
|
||||||
from mcstatus.querier import AsyncServerQuerier, QueryResponse, ServerQuerier
|
|
||||||
from mcstatus.utils import retry
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing_extensions import Self
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["MinecraftServer", "MinecraftBedrockServer"]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_address(address: str) -> Tuple[str, Optional[int]]:
|
|
||||||
tmp = urlparse("//" + address)
|
|
||||||
if not tmp.hostname:
|
|
||||||
raise ValueError(f"Invalid address '{address}'")
|
|
||||||
return (tmp.hostname, tmp.port)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_valid(host: object, port: object):
|
|
||||||
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})")
|
|
||||||
if port > 65535 or port < 0:
|
|
||||||
raise ValueError(f"Port must be within the allowed range (0-2^16), got {port}")
|
|
||||||
|
|
||||||
|
|
||||||
class MinecraftServer:
|
|
||||||
"""Base class for a Minecraft Java Edition server.
|
|
||||||
|
|
||||||
:param str host: The host/address/ip of the Minecraft server.
|
|
||||||
:param int port: The port that the server is on.
|
|
||||||
:param float timeout: The timeout in seconds before failing to connect.
|
|
||||||
:attr host:
|
|
||||||
:attr port:
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int = 25565, timeout: float = 3):
|
|
||||||
ensure_valid(host, port)
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def lookup(cls, address: str, timeout: float = 3) -> Self:
|
|
||||||
"""Parses the given address and checks DNS records for an SRV record that points to the Minecraft server.
|
|
||||||
|
|
||||||
:param str address: The address of the Minecraft server, like `example.com:25565`.
|
|
||||||
:param float timeout: The timeout in seconds before failing to connect.
|
|
||||||
:return: A `MinecraftServer` instance.
|
|
||||||
:rtype: MinecraftServer
|
|
||||||
"""
|
|
||||||
|
|
||||||
host, port = parse_address(address)
|
|
||||||
if port is None:
|
|
||||||
port = 25565
|
|
||||||
try:
|
|
||||||
answers = dns.resolver.resolve("_minecraft._tcp." + host, "SRV")
|
|
||||||
if len(answers):
|
|
||||||
answer = answers[0]
|
|
||||||
host = str(answer.target).rstrip(".")
|
|
||||||
port = int(answer.port)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return cls(host, port, timeout)
|
|
||||||
|
|
||||||
def ping(self, **kwargs) -> float:
|
|
||||||
"""Checks the latency between a Minecraft Java Edition server and the client (you).
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `ServerPinger` instance.
|
|
||||||
:return: The latency between the Minecraft Server and you.
|
|
||||||
:rtype: float
|
|
||||||
"""
|
|
||||||
|
|
||||||
connection = TCPSocketConnection((self.host, self.port), self.timeout)
|
|
||||||
return self._retry_ping(connection, **kwargs)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
def _retry_ping(self, connection: TCPSocketConnection, **kwargs) -> float:
|
|
||||||
pinger = ServerPinger(connection, host=self.host, port=self.port, **kwargs)
|
|
||||||
pinger.handshake()
|
|
||||||
return pinger.test_ping()
|
|
||||||
|
|
||||||
async def async_ping(self, **kwargs) -> float:
|
|
||||||
"""Asynchronously checks the latency between a Minecraft Java Edition server and the client (you).
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `AsyncServerPinger` instance.
|
|
||||||
:return: The latency between the Minecraft Server and you.
|
|
||||||
:rtype: float
|
|
||||||
"""
|
|
||||||
|
|
||||||
connection = TCPAsyncSocketConnection()
|
|
||||||
await connection.connect((self.host, self.port), self.timeout)
|
|
||||||
return await self._retry_async_ping(connection, **kwargs)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
async def _retry_async_ping(self, connection: TCPAsyncSocketConnection, **kwargs) -> float:
|
|
||||||
pinger = AsyncServerPinger(connection, host=self.host, port=self.port, **kwargs)
|
|
||||||
pinger.handshake()
|
|
||||||
ping = await pinger.test_ping()
|
|
||||||
return ping
|
|
||||||
|
|
||||||
def status(self, **kwargs) -> PingResponse:
|
|
||||||
"""Checks the status of a Minecraft Java Edition server via the ping protocol.
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `ServerPinger` instance.
|
|
||||||
:return: Status information in a `PingResponse` instance.
|
|
||||||
:rtype: PingResponse
|
|
||||||
"""
|
|
||||||
|
|
||||||
connection = TCPSocketConnection((self.host, self.port), self.timeout)
|
|
||||||
return self._retry_status(connection, **kwargs)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
def _retry_status(self, connection: TCPSocketConnection, **kwargs) -> PingResponse:
|
|
||||||
pinger = ServerPinger(connection, host=self.host, port=self.port, **kwargs)
|
|
||||||
pinger.handshake()
|
|
||||||
result = pinger.read_status()
|
|
||||||
result.latency = pinger.test_ping()
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def async_status(self, **kwargs) -> PingResponse:
|
|
||||||
"""Asynchronously checks the status of a Minecraft Java Edition server via the ping protocol.
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `AsyncServerPinger` instance.
|
|
||||||
:return: Status information in a `PingResponse` instance.
|
|
||||||
:rtype: PingResponse
|
|
||||||
"""
|
|
||||||
|
|
||||||
connection = TCPAsyncSocketConnection()
|
|
||||||
await connection.connect((self.host, self.port), self.timeout)
|
|
||||||
return await self._retry_async_status(connection, **kwargs)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
async def _retry_async_status(self, connection: TCPAsyncSocketConnection, **kwargs) -> PingResponse:
|
|
||||||
pinger = AsyncServerPinger(connection, host=self.host, port=self.port, **kwargs)
|
|
||||||
pinger.handshake()
|
|
||||||
result = await pinger.read_status()
|
|
||||||
result.latency = await pinger.test_ping()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def query(self) -> QueryResponse:
|
|
||||||
"""Checks the status of a Minecraft Java Edition server via the query protocol.
|
|
||||||
|
|
||||||
:return: Query status information in a `QueryResponse` instance.
|
|
||||||
:rtype: QueryResponse
|
|
||||||
"""
|
|
||||||
host = self.host
|
|
||||||
try:
|
|
||||||
answers = dns.resolver.resolve(host, "A")
|
|
||||||
if len(answers):
|
|
||||||
answer = answers[0]
|
|
||||||
host = str(answer).rstrip(".")
|
|
||||||
except DNSException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self._retry_query(host)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
def _retry_query(self, host: str) -> QueryResponse:
|
|
||||||
connection = UDPSocketConnection((host, self.port), self.timeout)
|
|
||||||
querier = ServerQuerier(connection)
|
|
||||||
querier.handshake()
|
|
||||||
return querier.read_query()
|
|
||||||
|
|
||||||
async def async_query(self) -> QueryResponse:
|
|
||||||
"""Asynchronously checks the status of a Minecraft Java Edition server via the query protocol.
|
|
||||||
|
|
||||||
:return: Query status information in a `QueryResponse` instance.
|
|
||||||
:rtype: QueryResponse
|
|
||||||
"""
|
|
||||||
host = self.host
|
|
||||||
try:
|
|
||||||
answers = dns.resolver.resolve(host, "A")
|
|
||||||
if len(answers):
|
|
||||||
answer = answers[0]
|
|
||||||
host = str(answer).rstrip(".")
|
|
||||||
except DNSException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return await self._retry_async_query(host)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
async def _retry_async_query(self, host) -> QueryResponse:
|
|
||||||
connection = UDPAsyncSocketConnection()
|
|
||||||
await connection.connect((host, self.port), self.timeout)
|
|
||||||
querier = AsyncServerQuerier(connection)
|
|
||||||
await querier.handshake()
|
|
||||||
return await querier.read_query()
|
|
||||||
|
|
||||||
|
|
||||||
class MinecraftBedrockServer:
|
|
||||||
"""Base class for a Minecraft Bedrock Edition server.
|
|
||||||
|
|
||||||
:param str host: The host/address/ip of the Minecraft server.
|
|
||||||
:param int port: The port that the server is on.
|
|
||||||
:param float timeout: The timeout in seconds before failing to connect.
|
|
||||||
:attr host:
|
|
||||||
:attr port:
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, host: str, port: int = 19132, timeout: float = 3):
|
|
||||||
ensure_valid(host, port)
|
|
||||||
self.host = host
|
|
||||||
self.port = port
|
|
||||||
self.timeout = timeout
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def lookup(cls, address: str) -> Self:
|
|
||||||
"""Parses a given address and returns a MinecraftBedrockServer instance.
|
|
||||||
|
|
||||||
:param str address: The address of the Minecraft server, like `example.com:19132`
|
|
||||||
:return: A `MinecraftBedrockServer` instance.
|
|
||||||
:rtype: MinecraftBedrockServer
|
|
||||||
"""
|
|
||||||
host, port = parse_address(address)
|
|
||||||
# If the address didn't contain port, fall back to constructor's default
|
|
||||||
if port is None:
|
|
||||||
return cls(host)
|
|
||||||
return cls(host, port)
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
def status(self, **kwargs) -> BedrockStatusResponse:
|
|
||||||
"""Checks the status of a Minecraft Bedrock Edition server.
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `BedrockServerStatus` instance.
|
|
||||||
:return: Status information in a `BedrockStatusResponse` instance.
|
|
||||||
:rtype: BedrockStatusResponse
|
|
||||||
"""
|
|
||||||
return BedrockServerStatus(self.host, self.port, self.timeout, **kwargs).read_status()
|
|
||||||
|
|
||||||
@retry(tries=3)
|
|
||||||
async def async_status(self, **kwargs) -> BedrockStatusResponse:
|
|
||||||
"""Asynchronously checks the status of a Minecraft Bedrock Edition server.
|
|
||||||
|
|
||||||
:param type **kwargs: Passed to a `BedrockServerStatus` instance.
|
|
||||||
:return: Status information in a `BedrockStatusResponse` instance.
|
|
||||||
:rtype: BedrockStatusResponse
|
|
||||||
"""
|
|
||||||
return await BedrockServerStatus(self.host, self.port, self.timeout, **kwargs).read_status_async()
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
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):
|
|
||||||
self.connection.read_varint()
|
|
||||||
|
|
||||||
def test_write_invalid_varint(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
self.connection.write_varint(2147483648)
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
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")
|
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class TCPSocketConnectionTest:
|
|
||||||
def setup_method(self):
|
|
||||||
socket = Mock()
|
|
||||||
socket.recv = Mock()
|
|
||||||
socket.send = Mock()
|
|
||||||
with patch("socket.create_connection") as create_connection:
|
|
||||||
create_connection.return_value = socket
|
|
||||||
self.connection = TCPSocketConnection(("localhost", 1234))
|
|
||||||
|
|
||||||
def test_flush(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.connection.flush()
|
|
||||||
|
|
||||||
def test_receive(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.connection.receive("") # type: ignore # This is desired to produce TypeError
|
|
||||||
|
|
||||||
def test_remaining(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.connection.remaining()
|
|
||||||
|
|
||||||
def test_read(self):
|
|
||||||
self.connection.socket.recv.return_value = bytearray.fromhex("7FAA")
|
|
||||||
|
|
||||||
assert self.connection.read(2) == bytearray.fromhex("7FAA")
|
|
||||||
|
|
||||||
def test_read_empty(self):
|
|
||||||
self.connection.socket.recv.return_value = bytearray.fromhex("")
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.connection.read(2)
|
|
||||||
|
|
||||||
def test_write(self):
|
|
||||||
self.connection.write(bytearray.fromhex("7FAA"))
|
|
||||||
|
|
||||||
self.connection.socket.send.assert_called_once_with(bytearray.fromhex("7FAA")) # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
|
|
||||||
class UDPSocketConnectionTest:
|
|
||||||
def setup_method(self):
|
|
||||||
socket = Mock()
|
|
||||||
socket.recvfrom = Mock()
|
|
||||||
socket.sendto = Mock()
|
|
||||||
with patch("socket.socket") as create_socket:
|
|
||||||
create_socket.return_value = socket
|
|
||||||
self.connection = UDPSocketConnection(("localhost", 1234))
|
|
||||||
|
|
||||||
def test_flush(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.connection.flush()
|
|
||||||
|
|
||||||
def test_receive(self):
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
self.connection.receive("") # type: ignore # This is desired to produce TypeError
|
|
||||||
|
|
||||||
def test_remaining(self):
|
|
||||||
assert self.connection.remaining() == 65535
|
|
||||||
|
|
||||||
def test_read(self):
|
|
||||||
self.connection.socket.recvfrom.return_value = [bytearray.fromhex("7FAA")]
|
|
||||||
|
|
||||||
assert self.connection.read(2) == bytearray.fromhex("7FAA")
|
|
||||||
|
|
||||||
def test_write(self):
|
|
||||||
self.connection.write(bytearray.fromhex("7FAA"))
|
|
||||||
|
|
||||||
self.connection.socket.sendto.assert_called_once_with( # type: ignore[attr-defined]
|
|
||||||
bytearray.fromhex("7FAA"),
|
|
||||||
("localhost", 1234),
|
|
||||||
)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcstatus.pinger import AsyncServerPinger
|
|
||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
|
|
||||||
|
|
||||||
def async_decorator(f):
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return loop.run_until_complete(f(*args, **kwargs))
|
|
||||||
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
class FakeAsyncConnection(Connection):
|
|
||||||
async def read_buffer(self):
|
|
||||||
return super().read_buffer()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAsyncServerPinger:
|
|
||||||
def setup_method(self):
|
|
||||||
self.pinger = AsyncServerPinger(
|
|
||||||
FakeAsyncConnection(), host="localhost", port=25565, version=44 # type: ignore[arg-type]
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_handshake(self):
|
|
||||||
self.pinger.handshake()
|
|
||||||
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
|
|
||||||
|
|
||||||
def test_read_status(self):
|
|
||||||
self.pinger.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
|
|
||||||
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
|
|
||||||
"70726F746F636F6C223A34347D7D"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
status = async_decorator(self.pinger.read_status)()
|
|
||||||
|
|
||||||
assert status.raw == {
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("0100")
|
|
||||||
|
|
||||||
def test_read_status_invalid_json(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("0300017B"))
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
async_decorator(self.pinger.test_ping)()
|
|
||||||
|
|
||||||
def test_read_status_invalid_reply(self):
|
|
||||||
self.pinger.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
|
|
||||||
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
async_decorator(self.pinger.test_ping)()
|
|
||||||
|
|
||||||
def test_read_status_invalid_status(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("0105"))
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
async_decorator(self.pinger.test_ping)()
|
|
||||||
|
|
||||||
def test_test_ping(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
|
|
||||||
self.pinger.ping_token = 14515484
|
|
||||||
|
|
||||||
assert async_decorator(self.pinger.test_ping)() >= 0
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
|
|
||||||
|
|
||||||
def test_test_ping_invalid(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("011F"))
|
|
||||||
self.pinger.ping_token = 14515484
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
async_decorator(self.pinger.test_ping)()
|
|
||||||
|
|
||||||
def test_test_ping_wrong_token(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
|
|
||||||
self.pinger.ping_token = 12345
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
async_decorator(self.pinger.test_ping)()
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
from mcstatus.querier import AsyncServerQuerier
|
|
||||||
from mcstatus.tests.test_async_pinger import async_decorator
|
|
||||||
|
|
||||||
|
|
||||||
class FakeUDPAsyncConnection(Connection):
|
|
||||||
async def read(self, length):
|
|
||||||
return super().read(length)
|
|
||||||
|
|
||||||
async def write(self, data):
|
|
||||||
return super().write(data)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMinecraftAsyncQuerier:
|
|
||||||
def setup_method(self):
|
|
||||||
self.querier = AsyncServerQuerier(FakeUDPAsyncConnection()) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_handshake(self):
|
|
||||||
self.querier.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
|
|
||||||
async_decorator(self.querier.handshake)()
|
|
||||||
conn_bytes = self.querier.connection.flush()
|
|
||||||
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
|
|
||||||
assert self.querier.challenge == 570350778
|
|
||||||
|
|
||||||
def test_query(self):
|
|
||||||
self.querier.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
|
|
||||||
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
|
|
||||||
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
|
|
||||||
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
|
|
||||||
"746576650000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
response = async_decorator(self.querier.read_query)()
|
|
||||||
conn_bytes = self.querier.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.names == ["Dinnerbone", "Djinnibone", "Steve"]
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from mcstatus.bedrock_status import BedrockServerStatus, BedrockStatusResponse
|
|
||||||
|
|
||||||
|
|
||||||
def test_bedrock_response_contains_expected_fields():
|
|
||||||
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 = BedrockServerStatus.parse_response(data, 1)
|
|
||||||
assert isinstance(parsed, BedrockStatusResponse)
|
|
||||||
assert "gamemode" in parsed.__dict__
|
|
||||||
assert "latency" in parsed.__dict__
|
|
||||||
assert "map" in parsed.__dict__
|
|
||||||
assert "motd" in parsed.__dict__
|
|
||||||
assert "players_max" in parsed.__dict__
|
|
||||||
assert "players_online" in parsed.__dict__
|
|
||||||
assert "version" in parsed.__dict__
|
|
||||||
assert "brand" in parsed.version.__dict__
|
|
||||||
assert "protocol" in parsed.version.__dict__
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from mcstatus.pinger import PingResponse, ServerPinger
|
|
||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
|
|
||||||
|
|
||||||
class TestServerPinger:
|
|
||||||
def setup_method(self):
|
|
||||||
self.pinger = ServerPinger(Connection(), host="localhost", port=25565, version=44) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_handshake(self):
|
|
||||||
self.pinger.handshake()
|
|
||||||
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("0F002C096C6F63616C686F737463DD01")
|
|
||||||
|
|
||||||
def test_read_status(self):
|
|
||||||
self.pinger.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"7200707B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
|
|
||||||
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E382D70726531222C22"
|
|
||||||
"70726F746F636F6C223A34347D7D"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
status = self.pinger.read_status()
|
|
||||||
|
|
||||||
assert status.raw == {
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("0100")
|
|
||||||
|
|
||||||
def test_read_status_invalid_json(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("0300017B"))
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.pinger.read_status()
|
|
||||||
|
|
||||||
def test_read_status_invalid_reply(self):
|
|
||||||
self.pinger.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"4F004D7B22706C6179657273223A7B226D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616"
|
|
||||||
"D65223A22312E382D70726531222C2270726F746F636F6C223A34347D7D"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.pinger.read_status()
|
|
||||||
|
|
||||||
def test_read_status_invalid_status(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("0105"))
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.pinger.read_status()
|
|
||||||
|
|
||||||
def test_test_ping(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
|
|
||||||
self.pinger.ping_token = 14515484
|
|
||||||
|
|
||||||
assert self.pinger.test_ping() >= 0
|
|
||||||
assert self.pinger.connection.flush() == bytearray.fromhex("09010000000000DD7D1C")
|
|
||||||
|
|
||||||
def test_test_ping_invalid(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("011F"))
|
|
||||||
self.pinger.ping_token = 14515484
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.pinger.test_ping()
|
|
||||||
|
|
||||||
def test_test_ping_wrong_token(self):
|
|
||||||
self.pinger.connection.receive(bytearray.fromhex("09010000000000DD7D1C"))
|
|
||||||
self.pinger.ping_token = 12345
|
|
||||||
|
|
||||||
with pytest.raises(IOError):
|
|
||||||
self.pinger.test_ping()
|
|
||||||
|
|
||||||
|
|
||||||
class TestPingResponse:
|
|
||||||
def test_raw(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.raw == {
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_description(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.description == "A Minecraft Server"
|
|
||||||
|
|
||||||
def test_description_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse(
|
|
||||||
{
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_parse_description_strips_html_color_codes(self):
|
|
||||||
out = PingResponse._parse_description(
|
|
||||||
{
|
|
||||||
"extra": [
|
|
||||||
{"text": " "},
|
|
||||||
{"strikethrough": True, "color": "#b3eeff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#b9ecff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#c0eaff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#c7e8ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#cee6ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#d5e4ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#dce2ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#e3e0ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#eadeff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f1dcff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f8daff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#ffd9ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f4dcff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f9daff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#ffd9ff", "text": "="},
|
|
||||||
{"color": "white", "text": " "},
|
|
||||||
{"bold": True, "color": "#66ff99", "text": "C"},
|
|
||||||
{"bold": True, "color": "#75f5a2", "text": "r"},
|
|
||||||
{"bold": True, "color": "#84ebab", "text": "e"},
|
|
||||||
{"bold": True, "color": "#93e2b4", "text": "a"},
|
|
||||||
{"bold": True, "color": "#a3d8bd", "text": "t"},
|
|
||||||
{"bold": True, "color": "#b2cfc6", "text": "i"},
|
|
||||||
{"bold": True, "color": "#c1c5cf", "text": "v"},
|
|
||||||
{"bold": True, "color": "#d1bbd8", "text": "e"},
|
|
||||||
{"bold": True, "color": "#e0b2e1", "text": "F"},
|
|
||||||
{"bold": True, "color": "#efa8ea", "text": "u"},
|
|
||||||
{"bold": True, "color": "#ff9ff4", "text": "n "},
|
|
||||||
{"strikethrough": True, "color": "#b3eeff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#b9ecff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#c0eaff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#c7e8ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#cee6ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#d5e4ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#dce2ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#e3e0ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#eadeff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f1dcff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f8daff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#ffd9ff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f4dcff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#f9daff", "text": "="},
|
|
||||||
{"strikethrough": True, "color": "#ffd9ff", "text": "="},
|
|
||||||
{"color": "white", "text": " \n "},
|
|
||||||
{"bold": True, "color": "#E5E5E5", "text": "The server has been updated to "},
|
|
||||||
{"bold": True, "color": "#97ABFF", "text": "1.17.1"},
|
|
||||||
],
|
|
||||||
"text": "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
assert out == (
|
|
||||||
" ===============§f §lC§lr§le§la§lt§li§lv§le§lF§lu§ln ===============§f \n"
|
|
||||||
" §lThe server has been updated to §l1.17.1"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_parse_description(self):
|
|
||||||
out = PingResponse._parse_description("test §2description")
|
|
||||||
assert out == "test §2description"
|
|
||||||
|
|
||||||
out = PingResponse._parse_description({"text": "§8§lfoo"})
|
|
||||||
assert out == "§8§lfoo"
|
|
||||||
|
|
||||||
out = PingResponse._parse_description(
|
|
||||||
{
|
|
||||||
"extra": [{"bold": True, "italic": True, "color": "gray", "text": "foo"}, {"color": "gold", "text": "bar"}],
|
|
||||||
"text": ".",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# We don't care in which order the style prefixes are, allow any
|
|
||||||
assert out in {
|
|
||||||
"§l§o§7foo§6bar.",
|
|
||||||
"§l§7§ofoo§6bar.",
|
|
||||||
"§o§l§7foo§6bar.",
|
|
||||||
"§o§7§lfoo§6bar.",
|
|
||||||
"§7§o§lfoo§6bar.",
|
|
||||||
"§7§l§ofoo§6bar.",
|
|
||||||
}
|
|
||||||
|
|
||||||
out = PingResponse._parse_description(
|
|
||||||
[{"bold": True, "italic": True, "color": "gray", "text": "foo"}, {"color": "gold", "text": "bar"}]
|
|
||||||
)
|
|
||||||
assert out in {
|
|
||||||
"§l§o§7foo§6bar",
|
|
||||||
"§l§7§ofoo§6bar",
|
|
||||||
"§o§l§7foo§6bar",
|
|
||||||
"§o§7§lfoo§6bar",
|
|
||||||
"§7§o§lfoo§6bar",
|
|
||||||
"§7§l§ofoo§6bar",
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_version(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.version is not None
|
|
||||||
assert response.version.name == "1.8-pre1"
|
|
||||||
assert response.version.protocol == 44
|
|
||||||
|
|
||||||
def test_version_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_version_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": "foo",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_players(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 5},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.players is not None
|
|
||||||
assert response.players.max == 20
|
|
||||||
assert response.players.online == 5
|
|
||||||
|
|
||||||
def test_players_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_favicon(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
"favicon": "data:image/png;base64,foo",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.favicon == "data:image/png;base64,foo"
|
|
||||||
|
|
||||||
def test_favicon_missing(self):
|
|
||||||
response = PingResponse(
|
|
||||||
{
|
|
||||||
"description": "A Minecraft Server",
|
|
||||||
"players": {"max": 20, "online": 0},
|
|
||||||
"version": {"name": "1.8-pre1", "protocol": 44},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.favicon is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestPingResponsePlayers:
|
|
||||||
def test_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players("foo")
|
|
||||||
|
|
||||||
def test_max_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players({"online": 5})
|
|
||||||
|
|
||||||
def test_max_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players({"max": "foo", "online": 5})
|
|
||||||
|
|
||||||
def test_online_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players({"max": 20})
|
|
||||||
|
|
||||||
def test_online_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players({"max": 20, "online": "foo"})
|
|
||||||
|
|
||||||
def test_valid(self):
|
|
||||||
players = PingResponse.Players({"max": 20, "online": 5})
|
|
||||||
|
|
||||||
assert players.max == 20
|
|
||||||
assert players.online == 5
|
|
||||||
|
|
||||||
def test_sample(self):
|
|
||||||
players = PingResponse.Players(
|
|
||||||
{
|
|
||||||
"max": 20,
|
|
||||||
"online": 1,
|
|
||||||
"sample": [{"name": "Dinnerbone", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert players.sample is not None
|
|
||||||
assert players.sample[0].name == "Dinnerbone"
|
|
||||||
|
|
||||||
def test_sample_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players({"max": 20, "online": 1, "sample": "foo"})
|
|
||||||
|
|
||||||
def test_sample_missing(self):
|
|
||||||
players = PingResponse.Players({"max": 20, "online": 1})
|
|
||||||
assert players.sample is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestPingResponsePlayersPlayer:
|
|
||||||
def test_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players.Player("foo")
|
|
||||||
|
|
||||||
def test_name_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players.Player({"id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"})
|
|
||||||
|
|
||||||
def test_name_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players.Player({"name": {}, "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"})
|
|
||||||
|
|
||||||
def test_id_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players.Player({"name": "Dinnerbone"})
|
|
||||||
|
|
||||||
def test_id_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Players.Player({"name": "Dinnerbone", "id": {}})
|
|
||||||
|
|
||||||
def test_valid(self):
|
|
||||||
player = PingResponse.Players.Player({"name": "Dinnerbone", "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"})
|
|
||||||
|
|
||||||
assert player.name == "Dinnerbone"
|
|
||||||
assert player.id == "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPingResponseVersion:
|
|
||||||
def test_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Version("foo")
|
|
||||||
|
|
||||||
def test_protocol_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Version({"name": "foo"})
|
|
||||||
|
|
||||||
def test_protocol_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Version({"name": "foo", "protocol": "bar"})
|
|
||||||
|
|
||||||
def test_name_missing(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Version({"protocol": 5})
|
|
||||||
|
|
||||||
def test_name_invalid(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
PingResponse.Version({"name": {}, "protocol": 5})
|
|
||||||
|
|
||||||
def test_valid(self):
|
|
||||||
players = PingResponse.Version({"name": "foo", "protocol": 5})
|
|
||||||
|
|
||||||
assert players.name == "foo"
|
|
||||||
assert players.protocol == 5
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
from mcstatus.querier import QueryResponse, ServerQuerier
|
|
||||||
|
|
||||||
|
|
||||||
class TestMinecraftQuerier:
|
|
||||||
def setup_method(self):
|
|
||||||
self.querier = ServerQuerier(Connection()) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_handshake(self):
|
|
||||||
self.querier.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
|
|
||||||
self.querier.handshake()
|
|
||||||
|
|
||||||
conn_bytes = self.querier.connection.flush()
|
|
||||||
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
|
|
||||||
assert self.querier.challenge == 570350778
|
|
||||||
|
|
||||||
def test_query(self):
|
|
||||||
self.querier.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
|
|
||||||
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
|
|
||||||
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
|
|
||||||
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
|
|
||||||
"746576650000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
response = self.querier.read_query()
|
|
||||||
conn_bytes = self.querier.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.names == ["Dinnerbone", "Djinnibone", "Steve"]
|
|
||||||
|
|
||||||
def test_query_handles_unicode_motd_with_nulls(self):
|
|
||||||
self.querier.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.querier.read_query()
|
|
||||||
self.querier.connection.flush()
|
|
||||||
|
|
||||||
assert response.raw["game_id"] == "MINECRAFT"
|
|
||||||
assert response.motd == "\x00*KÕ"
|
|
||||||
|
|
||||||
def test_query_handles_unicode_motd_with_2a00_at_the_start(self):
|
|
||||||
self.querier.connection.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"00000000000000000000000000000000686f73746e616d6500006f746865720067616d657479706500534d500067616d655f6964004d"
|
|
||||||
"494e4543524146540076657273696f6e00312e31382e3100706c7567696e7300006d617000776f726c64006e756d706c617965727300"
|
|
||||||
"30006d6178706c617965727300323000686f7374706f727400323535363500686f73746970003137322e31372e302e32000001706c61"
|
|
||||||
"7965725f000000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
response = self.querier.read_query()
|
|
||||||
self.querier.connection.flush()
|
|
||||||
|
|
||||||
assert response.raw["game_id"] == "MINECRAFT"
|
|
||||||
assert response.motd == "\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".
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueryResponse:
|
|
||||||
def setup_method(self):
|
|
||||||
self.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",
|
|
||||||
}
|
|
||||||
self.players = ["Dinnerbone", "Djinnibone", "Steve"]
|
|
||||||
|
|
||||||
def test_valid(self):
|
|
||||||
response = QueryResponse(self.raw, self.players)
|
|
||||||
assert response.motd == "A Minecraft Server"
|
|
||||||
assert response.map == "world"
|
|
||||||
assert response.players.online == 3
|
|
||||||
assert response.players.max == 20
|
|
||||||
assert response.players.names == ["Dinnerbone", "Djinnibone", "Steve"]
|
|
||||||
assert response.software.brand == "vanilla"
|
|
||||||
assert response.software.version == "1.8"
|
|
||||||
assert response.software.plugins == []
|
|
||||||
|
|
||||||
def test_valid2(self):
|
|
||||||
players = QueryResponse.Players(5, 20, ["Dinnerbone", "Djinnibone", "Steve"])
|
|
||||||
assert players.online == 5
|
|
||||||
assert players.max == 20
|
|
||||||
assert players.names == ["Dinnerbone", "Djinnibone", "Steve"]
|
|
||||||
|
|
||||||
def test_vanilla(self):
|
|
||||||
software = QueryResponse.Software("1.8", "")
|
|
||||||
assert software.brand == "vanilla"
|
|
||||||
assert software.version == "1.8"
|
|
||||||
assert software.plugins == []
|
|
||||||
|
|
||||||
def test_modded(self):
|
|
||||||
software = QueryResponse.Software("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 = QueryResponse.Software("1.8", "A modded server")
|
|
||||||
assert software.brand == "A modded server"
|
|
||||||
assert software.plugins == []
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from mcstatus.tests.test_async_pinger import async_decorator
|
|
||||||
from mcstatus.utils import retry
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
elif x == 1:
|
|
||||||
raise RuntimeError("Second error")
|
|
||||||
|
|
||||||
# We should get the last exception on failure (not OSError)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
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")
|
|
||||||
elif x == 1:
|
|
||||||
raise RuntimeError("Second error")
|
|
||||||
|
|
||||||
# We should get the last exception on failure (not OSError)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
async_decorator(func)()
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
from mcstatus.server import MinecraftServer
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
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.fixture()
|
|
||||||
async def create_mock_packet_server(event_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 TestAsyncMinecraftServer:
|
|
||||||
@pytest.mark.skipif(
|
|
||||||
sys.platform.startswith("win"),
|
|
||||||
reason="async bug on Windows https://github.com/Dinnerbone/mcstatus/issues/140",
|
|
||||||
)
|
|
||||||
@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 = MinecraftServer("localhost", port=unused_tcp_port)
|
|
||||||
|
|
||||||
latency = await minecraft_server.async_ping(ping_token=29704774, version=47)
|
|
||||||
assert latency >= 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestMinecraftServer:
|
|
||||||
def setup_method(self):
|
|
||||||
self.socket = Connection()
|
|
||||||
self.server = MinecraftServer("localhost", port=25565)
|
|
||||||
|
|
||||||
def test_ping(self):
|
|
||||||
self.socket.receive(bytearray.fromhex("09010000000001C54246"))
|
|
||||||
|
|
||||||
with patch("mcstatus.server.TCPSocketConnection") as connection:
|
|
||||||
connection.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):
|
|
||||||
with patch("mcstatus.server.TCPSocketConnection") as connection:
|
|
||||||
connection.return_value = None
|
|
||||||
with patch("mcstatus.server.ServerPinger") as pinger:
|
|
||||||
pinger.side_effect = [Exception, Exception, Exception]
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
self.server.ping()
|
|
||||||
assert pinger.call_count == 3
|
|
||||||
|
|
||||||
def test_status(self):
|
|
||||||
self.socket.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"6D006B7B226465736372697074696F6E223A2241204D696E65637261667420536572766572222C22706C6179657273223A7B2"
|
|
||||||
"26D6178223A32302C226F6E6C696E65223A307D2C2276657273696F6E223A7B226E616D65223A22312E38222C2270726F746F"
|
|
||||||
"636F6C223A34377D7D09010000000001C54246"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("mcstatus.server.TCPSocketConnection") as connection:
|
|
||||||
connection.return_value = self.socket
|
|
||||||
info = self.server.status(ping_token=29704774, version=47)
|
|
||||||
|
|
||||||
assert self.socket.flush() == bytearray.fromhex("0F002F096C6F63616C686F737463DD01010009010000000001C54246")
|
|
||||||
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):
|
|
||||||
with patch("mcstatus.server.TCPSocketConnection") as connection:
|
|
||||||
connection.return_value = None
|
|
||||||
with patch("mcstatus.server.ServerPinger") as pinger:
|
|
||||||
pinger.side_effect = [Exception, Exception, Exception]
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
self.server.status()
|
|
||||||
assert pinger.call_count == 3
|
|
||||||
|
|
||||||
def test_query(self):
|
|
||||||
self.socket.receive(bytearray.fromhex("090000000035373033353037373800"))
|
|
||||||
self.socket.receive(
|
|
||||||
bytearray.fromhex(
|
|
||||||
"00000000000000000000000000000000686f73746e616d650041204d696e656372616674205365727665720067616d6574797"
|
|
||||||
"06500534d500067616d655f6964004d494e4543524146540076657273696f6e00312e3800706c7567696e7300006d61700077"
|
|
||||||
"6f726c64006e756d706c61796572730033006d6178706c617965727300323000686f7374706f727400323535363500686f737"
|
|
||||||
"46970003139322e3136382e35362e31000001706c617965725f000044696e6e6572626f6e6500446a696e6e69626f6e650053"
|
|
||||||
"746576650000"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.socket.remaining = Mock()
|
|
||||||
self.socket.remaining.side_effect = [15, 208]
|
|
||||||
|
|
||||||
with patch("mcstatus.server.UDPSocketConnection") as connection:
|
|
||||||
connection.return_value = self.socket
|
|
||||||
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):
|
|
||||||
with patch("mcstatus.server.UDPSocketConnection") as connection:
|
|
||||||
connection.return_value = None
|
|
||||||
with patch("mcstatus.server.ServerQuerier") as querier:
|
|
||||||
querier.side_effect = [Exception, Exception, Exception]
|
|
||||||
with pytest.raises(Exception):
|
|
||||||
self.server.query()
|
|
||||||
assert querier.call_count == 3
|
|
||||||
|
|
||||||
def test_by_address_no_srv(self):
|
|
||||||
with patch("dns.resolver.resolve") as resolve:
|
|
||||||
resolve.return_value = []
|
|
||||||
self.server = MinecraftServer.lookup("example.org")
|
|
||||||
resolve.assert_called_once_with("_minecraft._tcp.example.org", "SRV")
|
|
||||||
assert self.server.host == "example.org"
|
|
||||||
assert self.server.port == 25565
|
|
||||||
|
|
||||||
def test_by_address_invalid_srv(self):
|
|
||||||
with patch("dns.resolver.resolve") as resolve:
|
|
||||||
resolve.side_effect = [Exception]
|
|
||||||
self.server = MinecraftServer.lookup("example.org")
|
|
||||||
resolve.assert_called_once_with("_minecraft._tcp.example.org", "SRV")
|
|
||||||
assert self.server.host == "example.org"
|
|
||||||
assert self.server.port == 25565
|
|
||||||
|
|
||||||
def test_by_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]
|
|
||||||
self.server = MinecraftServer.lookup("example.org")
|
|
||||||
resolve.assert_called_once_with("_minecraft._tcp.example.org", "SRV")
|
|
||||||
assert self.server.host == "different.example.org"
|
|
||||||
assert self.server.port == 12345
|
|
||||||
|
|
||||||
def test_by_address_with_port(self):
|
|
||||||
self.server = MinecraftServer.lookup("example.org:12345")
|
|
||||||
assert self.server.host == "example.org"
|
|
||||||
assert self.server.port == 12345
|
|
||||||
|
|
||||||
def test_by_address_with_multiple_ports(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
MinecraftServer.lookup("example.org:12345:6789")
|
|
||||||
|
|
||||||
def test_by_address_with_invalid_port(self):
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
MinecraftServer.lookup("example.org:port")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from mcstatus.protocol.connection import Connection
|
|
||||||
from mcstatus.querier import ServerQuerier
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandshake:
|
|
||||||
def setup_method(self):
|
|
||||||
self.querier = ServerQuerier(Connection()) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
def test_session_id(self):
|
|
||||||
def session_id():
|
|
||||||
return 0x01010101
|
|
||||||
|
|
||||||
self.querier.connection.receive(bytearray.fromhex("090000000035373033353037373800"))
|
|
||||||
self.querier._generate_session_id = Mock()
|
|
||||||
self.querier._generate_session_id = session_id
|
|
||||||
self.querier.handshake()
|
|
||||||
|
|
||||||
conn_bytes = self.querier.connection.flush()
|
|
||||||
assert conn_bytes[:3] == bytearray.fromhex("FEFD09")
|
|
||||||
assert conn_bytes[3:] == session_id().to_bytes(4, byteorder="big")
|
|
||||||
assert self.querier.challenge == 570350778
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from mcstatus.protocol.connection import TCPAsyncSocketConnection
|
|
||||||
|
|
||||||
|
|
||||||
class FakeAsyncStream(asyncio.StreamReader):
|
|
||||||
async def read(self, n: int) -> bytes:
|
|
||||||
await asyncio.sleep(delay=2)
|
|
||||||
return bytes([0] * n)
|
|
||||||
|
|
||||||
|
|
||||||
async def fake_asyncio_asyncio_open_connection(hostname: str, port: int):
|
|
||||||
return FakeAsyncStream(), None
|
|
||||||
|
|
||||||
|
|
||||||
class TestAsyncSocketConnection:
|
|
||||||
def setup_method(self):
|
|
||||||
self.tcp_async_socket = TCPAsyncSocketConnection()
|
|
||||||
|
|
||||||
def test_tcp_socket_read(self):
|
|
||||||
try:
|
|
||||||
from asyncio.exceptions import TimeoutError
|
|
||||||
except ImportError:
|
|
||||||
from asyncio import TimeoutError
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
with patch("asyncio.open_connection", fake_asyncio_asyncio_open_connection):
|
|
||||||
loop.run_until_complete(self.tcp_async_socket.connect(("dummy_address", 1234), timeout=0.01))
|
|
||||||
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
loop.run_until_complete(self.tcp_async_socket.read(10))
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from functools import wraps
|
|
||||||
from typing import Callable, Tuple, Type
|
|
||||||
|
|
||||||
|
|
||||||
def retry(tries: int, exceptions: Tuple[Type[BaseException]] = (Exception,)) -> Callable:
|
|
||||||
"""
|
|
||||||
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 precedense.
|
|
||||||
|
|
||||||
If the function fails even after all of the retries, raise the last
|
|
||||||
exception that the function raised (even if the previous failures caused
|
|
||||||
a different exception, this will only raise the last one!).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def decorate(func: Callable) -> Callable:
|
|
||||||
@wraps(func)
|
|
||||||
async def async_wrapper(*args, tries: int = tries, **kwargs):
|
|
||||||
last_exc: BaseException
|
|
||||||
for _ in range(tries):
|
|
||||||
try:
|
|
||||||
return await func(*args, **kwargs)
|
|
||||||
except exceptions as exc:
|
|
||||||
last_exc = exc
|
|
||||||
else:
|
|
||||||
raise last_exc # type: ignore # (This won't actually be unbound)
|
|
||||||
|
|
||||||
@wraps(func)
|
|
||||||
def sync_wrapper(*args, tries: int = tries, **kwargs):
|
|
||||||
last_exc: BaseException
|
|
||||||
for _ in range(tries):
|
|
||||||
try:
|
|
||||||
return func(*args, **kwargs)
|
|
||||||
except exceptions as exc:
|
|
||||||
last_exc = exc
|
|
||||||
else:
|
|
||||||
raise last_exc # type: ignore # (This won't actually be unbound)
|
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
|
||||||
return async_wrapper
|
|
||||||
return sync_wrapper
|
|
||||||
|
|
||||||
return decorate
|
|
||||||
1201
poetry.lock
generated
1201
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
|||||||
[tool.poetry]
|
|
||||||
name = "mcstatus"
|
|
||||||
version = "0.0.0" # version is handled by git tags and poetry-dynamic-versioning
|
|
||||||
description = "A library to query Minecraft Servers for their status and capabilities."
|
|
||||||
authors = ["Nathan Adams <dinnerbone@dinnerbone.com>"]
|
|
||||||
license = "Apache-2.0"
|
|
||||||
readme = "README.md"
|
|
||||||
repository = "https://github.com/Dinnerbone/mcstatus"
|
|
||||||
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.7",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Topic :: Games/Entertainment",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
||||||
"Topic :: System :: Monitoring",
|
|
||||||
]
|
|
||||||
packages = [
|
|
||||||
{ include = "mcstatus" },
|
|
||||||
{ include = "protocol", from = "mcstatus" },
|
|
||||||
{ include = "scripts", from = "mcstatus" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = ">=3.7,<4"
|
|
||||||
asyncio-dgram = "1.2.0"
|
|
||||||
click = ">=7.1.2,<9"
|
|
||||||
dnspython = "2.1.0"
|
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
|
||||||
coverage = "^6.1.1"
|
|
||||||
pytest = "^6.2.5"
|
|
||||||
pytest-asyncio = "^0.16.0"
|
|
||||||
pytest-cov = "^3.0.0"
|
|
||||||
twine = "^3.5.0"
|
|
||||||
black = "^22.1.0"
|
|
||||||
tox = "^3.24.5"
|
|
||||||
tox-poetry = "^0.4.1"
|
|
||||||
pyright = "^0.0.13"
|
|
||||||
typing-extensions = "^4.0.1"
|
|
||||||
flake8 = "^4.0.1"
|
|
||||||
flake8-bugbear = "^22.1.11"
|
|
||||||
flake8-tidy-imports = "^4.6.0"
|
|
||||||
flake8-import-order = "^0.18.1"
|
|
||||||
pep8-naming = "^0.12.1"
|
|
||||||
flake8-annotations = "^2.7.0"
|
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
|
||||||
mcstatus = 'mcstatus.scripts.mcstatus:cli'
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
|
||||||
minversion = "6.0"
|
|
||||||
addopts = "--strict-markers --doctest-modules --cov=mcstatus --cov-append --cov-branch --cov-report=term-missing -vvv --no-cov-on-fail"
|
|
||||||
testpaths = ["mcstatus/tests"]
|
|
||||||
|
|
||||||
[tool.poetry-dynamic-versioning]
|
|
||||||
bump = true
|
|
||||||
enable = true
|
|
||||||
style = "pep440"
|
|
||||||
|
|
||||||
[tool.black]
|
|
||||||
line-length = 127
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"exclude": [
|
|
||||||
// Don't type-check files in the virtual environment, it contains
|
|
||||||
// external library code which we don't need to check
|
|
||||||
".venv",
|
|
||||||
// GitHub validation workflow creates .cache folder to store the
|
|
||||||
// python environment into, we don't want to check the files in it
|
|
||||||
// for the same reason as with venv
|
|
||||||
".cache"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
14
release.sh
14
release.sh
@@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
poetry install
|
|
||||||
poetry run tox --recreate
|
|
||||||
|
|
||||||
rm -rf dist/
|
|
||||||
poetry build
|
|
||||||
|
|
||||||
echo "Set these environment variables if you wish to not be pestered:"
|
|
||||||
echo "export POETRY_PYPI_TOKEN_PYPI=my-token"
|
|
||||||
echo "export POETRY_HTTP_BASIC_PYPI_USERNAME=username"
|
|
||||||
echo "export POETRY_HTTP_BASIC_PYPI_PASSWORD=password"
|
|
||||||
poetry publish --no-interaction
|
|
||||||
41
tox.ini
41
tox.ini
@@ -1,41 +0,0 @@
|
|||||||
[tox]
|
|
||||||
isolated_build = True
|
|
||||||
envlist =
|
|
||||||
format-check,lint,py{37,38,39,310},coverage
|
|
||||||
|
|
||||||
[gh-actions]
|
|
||||||
python =
|
|
||||||
3.7: py37
|
|
||||||
3.8: py38
|
|
||||||
3.9: py39
|
|
||||||
3.10: py310, coverage
|
|
||||||
|
|
||||||
[testenv]
|
|
||||||
setenv =
|
|
||||||
COVERAGE_FILE=.coverage.{envname}
|
|
||||||
commands =
|
|
||||||
pytest {posargs}
|
|
||||||
|
|
||||||
[testenv:format]
|
|
||||||
commands =
|
|
||||||
black ./mcstatus
|
|
||||||
|
|
||||||
[testenv:format-check]
|
|
||||||
commands =
|
|
||||||
black --check ./mcstatus
|
|
||||||
|
|
||||||
[testenv:lint]
|
|
||||||
passenv = HOME
|
|
||||||
commands =
|
|
||||||
pyright ./mcstatus
|
|
||||||
flake8 ./mcstatus
|
|
||||||
|
|
||||||
[testenv:coverage]
|
|
||||||
depends =
|
|
||||||
py{37,38,39,310}
|
|
||||||
setenv =
|
|
||||||
COVERAGE_FILE=.coverage
|
|
||||||
commands =
|
|
||||||
coverage combine
|
|
||||||
coverage report --show-missing --fail-under=80
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user