Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CLI: Bedrock support and misc improvements #849

Merged
merged 36 commits into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2ee93a5
fix crash with one command-line argument
katrinafyi Jul 15, 2024
ec1a0f5
implement ping() on BedrockServer
katrinafyi Jul 15, 2024
60e3512
support Bedrock servers in CLI
katrinafyi Jul 15, 2024
a0e41a2
print server kind and tweak player sample printing
katrinafyi Jul 15, 2024
47ce944
JavaServer ping() doesn't work?
katrinafyi Jul 16, 2024
213fc2a
fix precommit warnings
katrinafyi Jul 17, 2024
848fc57
review: remove Bedrock ping()
katrinafyi Jul 17, 2024
f9577b5
review: change CLI ping comment to be more permanent
katrinafyi Jul 17, 2024
3a0ee8c
review: formalise hostip/hostport within QueryResponse
katrinafyi Jul 17, 2024
73a4543
review: only squash traceback in common errors
katrinafyi Jul 17, 2024
47f62fc
review: leading line break for multi-line motd
katrinafyi Jul 19, 2024
1130b99
Revert "review: formalise hostip/hostport within QueryResponse"
katrinafyi Jul 19, 2024
bb51ac5
review: use motd.to_minecraft() in json
katrinafyi Jul 19, 2024
b6a28fa
review amendment: factor out motd line breaking
katrinafyi Jul 19, 2024
de8be4d
review: refactor CLI json() to use dataclasses.asdict()
katrinafyi Jul 20, 2024
faf208b
amendment: add NoNameservers and remove ValueError from squashed errors
katrinafyi Jul 19, 2024
890d378
review: fallback logic in CLI ping
katrinafyi Jul 20, 2024
80079b3
review: use ip/port fields in CLI's JSON output
katrinafyi Jul 20, 2024
05d37a5
review: avoid kind() classmethod
katrinafyi Jul 20, 2024
6155980
review: clarify MOTD serialisation comment
katrinafyi Jul 20, 2024
b42b1b7
review: simplify ping fallback logic
katrinafyi Jul 20, 2024
6ae1dbf
make version consistent between status and query
katrinafyi Jul 21, 2024
ceb43ff
review: apply simplify() to motd in CLI JSON output
katrinafyi Jul 21, 2024
1799c3d
review: use separate JSON field for simplified MOTD
katrinafyi Jul 22, 2024
50b825e
review: remove MOTD fixup comment
katrinafyi Jul 22, 2024
c23d40d
review: update README with new CLI
katrinafyi Jul 22, 2024
4ad7f6c
review: no raw motd
katrinafyi Jul 23, 2024
8d29673
no --help output in readme
katrinafyi Jul 24, 2024
06e8b80
review: allow main() with no arguments
katrinafyi Jul 25, 2024
f82ae9e
Update mcstatus/__main__.py
katrinafyi Jul 25, 2024
80fb48b
avoid json collision
katrinafyi Jul 25, 2024
f4fdabd
oops! good linter
katrinafyi Jul 25, 2024
2540089
drike review
katrinafyi Jul 25, 2024
551caa4
good linter
katrinafyi Jul 25, 2024
9189de7
one more ci failure and i turn on the computer
katrinafyi Jul 25, 2024
d690735
also squash ConnectionError
katrinafyi Jul 27, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ See the [documentation](https://mcstatus.readthedocs.io) to find what you can do

### Command Line Interface

This only works with Java servers; Bedrock is not yet supported. Use `mcstatus -h` to see helpful information on how to use this script.
The mcstatus library includes a simple CLI. Once installed, it can be used through:
```bash
python3 -m mcstatus --help
kevinkjt2000 marked this conversation as resolved.
Show resolved Hide resolved
```

## License

Expand Down
180 changes: 133 additions & 47 deletions mcstatus/__main__.py
Original file line number Diff line number Diff line change
@@ -1,75 +1,152 @@
from __future__ import annotations
PerchunPak marked this conversation as resolved.
Show resolved Hide resolved

import dns.resolver
import sys
import json as _json
kevinkjt2000 marked this conversation as resolved.
Show resolved Hide resolved
import argparse
import socket
from json import dumps as json_dumps
import warnings
import dataclasses
from typing import TYPE_CHECKING

from mcstatus import JavaServer
from mcstatus import JavaServer, BedrockServer
from mcstatus.responses import JavaStatusResponse
from mcstatus.motd import Motd

if TYPE_CHECKING:
SupportedServers = JavaServer | BedrockServer

def ping(server: JavaServer) -> None:
print(f"{server.ping()}ms")

def _motd(motd: Motd) -> str:
"""Formats MOTD for human-readable output, with leading line break
if multiline."""
s = motd.to_ansi()
return f"\n{s}" if "\n" in s else f" {s}"

def status(server: JavaServer) -> None:

def _kind(serv: SupportedServers) -> str:
if isinstance(serv, JavaServer):
return "Java"
elif isinstance(serv, BedrockServer):
return "Bedrock"
else:
raise ValueError(f"unsupported server for kind: {serv}")


def _ping_with_fallback(server: SupportedServers) -> float:
# bedrock doesn't have ping method
if isinstance(server, BedrockServer):
return server.status().latency

# try faster ping packet first, falling back to status with a warning.
ping_exc = None
try:
return server.ping(tries=1)
except Exception as e:
ping_exc = e

latency = server.status().latency

address = f"{server.address.host}:{server.address.port}"
warnings.warn(
ItsDrike marked this conversation as resolved.
Show resolved Hide resolved
f"contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n"
f" this is likely a bug in the server-side implementation.\n"
f' (note: ping packet failed due to "{ping_exc}")\n'
f" for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n",
stacklevel=1,
)

return latency


def ping(server: SupportedServers) -> int:
print(f"{_ping_with_fallback(server)}")
katrinafyi marked this conversation as resolved.
Show resolved Hide resolved
return 0


def status(server: SupportedServers) -> int:
response = server.status()
if response.players.sample is not None:
player_sample = str([f"{player.name} ({player.id})" for player in response.players.sample])

java_res = response if isinstance(response, JavaStatusResponse) else None

if not java_res:
player_sample = ""
elif java_res.players.sample is not None:
player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample])
else:
player_sample = "No players online"

print(f"version: v{response.version.name} (protocol {response.version.protocol})")
print(f'motd: "{response.motd}"')
print(f"players: {response.players.online}/{response.players.max} {player_sample}")
if player_sample:
player_sample = " " + player_sample

print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})")
print(f"motd:{_motd(response.motd)}")
print(f"players: {response.players.online}/{response.players.max}{player_sample}")
print(f"ping: {response.latency:.2f} ms")
return 0

def json(server: JavaServer) -> None:
data = {}
data["online"] = False
# Build data with responses and quit on exception

def json(server: SupportedServers) -> int:
data = {"online": False, "kind": _kind(server)}

status_res = query_res = None
try:
status_res = server.status(tries=1)
data["version"] = status_res.version.name
data["protocol"] = status_res.version.protocol
data["motd"] = status_res.motd.raw
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]

data["ping"] = status_res.latency
data["online"] = True

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
print(json_dumps(data))


def query(server: JavaServer) -> None:
if isinstance(server, JavaServer):
query_res = server.query(tries=1)
except Exception as e:
if status_res is None:
data["error"] = str(e)

# construct 'data' dict outside try/except to ensure data processing errors
# are noticed.
data["online"] = bool(status_res or query_res)
ItsDrike marked this conversation as resolved.
Show resolved Hide resolved
if status_res is not None:
data["status"] = dataclasses.asdict(status_res)

assert "motd" in data["status"]
ItsDrike marked this conversation as resolved.
Show resolved Hide resolved
data["status"]["motd"] = status_res.motd.simplify().to_minecraft()

if query_res is not None:
# TODO: QueryResponse is not (yet?) a dataclass
data["query"] = qdata = {}

qdata["ip"] = query_res.raw["hostip"]
qdata["port"] = query_res.raw["hostport"]
qdata["map"] = query_res.map
qdata["plugins"] = query_res.software.plugins
qdata["raw"] = query_res.raw

_json.dump(data, sys.stdout)
return 0


def query(server: SupportedServers) -> int:
if not isinstance(server, JavaServer):
print("The 'query' protocol is only supported by Java servers.", file=sys.stderr)
return 1

try:
response = server.query()
except 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."
"\nSee https://wiki.vg/Query for further information.",
file=sys.stderr,
)
return
return 1

print(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
print(f"software: v{response.software.version} {response.software.brand}")
print(f"software: {_kind(server)} {response.software.version} {response.software.brand}")
print(f"motd:{_motd(response.motd)}")
print(f"plugins: {response.software.plugins}")
print(f'motd: "{response.motd}"')
print(f"players: {response.players.online}/{response.players.max} {response.players.names}")
return 0


def main() -> None:
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(
"mcstatus",
description="""
Expand All @@ -80,8 +157,11 @@ def main() -> None:
)

parser.add_argument("address", help="The address of the server.")
parser.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true")

subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.")
parser.set_defaults(func=status)

subparsers = parser.add_subparsers()
subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping)
subparsers.add_parser(
"status", help="Prints server status. Supported by all Minecraft servers that are version 1.7 or higher."
Expand All @@ -94,11 +174,17 @@ def main() -> None:
help="Prints server status and query in json. Supported by all Minecraft servers that are version 1.7 or higher.",
).set_defaults(func=json)

args = parser.parse_args()
server = JavaServer.lookup(args.address)
args = parser.parse_args(argv)
lookup = JavaServer.lookup if not args.bedrock else BedrockServer.lookup

args.func(server)
try:
server = lookup(args.address)
return args.func(server)
except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers) as e:
# catch and hide traceback for expected user-facing errors
print(f"Error: {e}", file=sys.stderr)
return 1


if __name__ == "__main__":
main()
sys.exit(main(sys.argv[1:]))
Loading