Skip to content

Commit a6c9a61

Browse files
katrinafyiPerchunPakkevinkjt2000
authored
CLI: Bedrock support and misc improvements (#849)
* fix crash with one command-line argument * implement ping() on BedrockServer simply measures the latency of status() * support Bedrock servers in CLI done in a slightly ad-hoc way, but this is the best we can do given the split of the response types. * print server kind and tweak player sample printing * JavaServer ping() doesn't work? * fix precommit warnings * review: remove Bedrock ping() * review: change CLI ping comment to be more permanent * review: formalise hostip/hostport within QueryResponse * review: only squash traceback in common errors * review: leading line break for multi-line motd * Revert "review: formalise hostip/hostport within QueryResponse" This reverts commit 3a0ee8c. * review: use motd.to_minecraft() in json * review amendment: factor out motd line breaking * review: refactor CLI json() to use dataclasses.asdict() * amendment: add NoNameservers and remove ValueError from squashed errors ValueError might be thrown by programming errors in json handling, for example. * review: fallback logic in CLI ping since this runs both ping() then status(), it can report precisely when one fails and the other succeeds. some kludgy logic to switch bedrock too. * review: use ip/port fields in CLI's JSON output in anticipation of #536 Co-authored-by: Perchun Pak <github@perchun.it> * review: avoid kind() classmethod * review: clarify MOTD serialisation comment * review: simplify ping fallback logic Co-authored-by: Perchun Pak <github@perchun.it> * make version consistent between status and query * review: apply simplify() to motd in CLI JSON output Co-authored-by: Perchun Pak <github@perchun.it> * review: use separate JSON field for simplified MOTD * review: remove MOTD fixup comment * review: update README with new CLI * review: no raw motd * no --help output in readme * review: allow main() with no arguments * Update mcstatus/__main__.py Co-authored-by: Kevin Tindall <kevinkjt2000@users.noreply.github.com> * avoid json collision * oops! good linter * drike review * good linter * one more ci failure and i turn on the computer * also squash ConnectionError happens during server startup, for example --------- Co-authored-by: Perchun Pak <github@perchun.it> Co-authored-by: Kevin Tindall <kevinkjt2000@users.noreply.github.com>
1 parent d3408a2 commit a6c9a61

File tree

2 files changed

+148
-52
lines changed

2 files changed

+148
-52
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ See the [documentation](https://mcstatus.readthedocs.io) to find what you can do
6767

6868
### Command Line Interface
6969

70-
This only works with Java servers; Bedrock is not yet supported. Use `mcstatus -h` to see helpful information on how to use this script.
70+
The mcstatus library includes a simple CLI. Once installed, it can be used through:
71+
```bash
72+
python3 -m mcstatus --help
73+
```
7174

7275
## License
7376

mcstatus/__main__.py

Lines changed: 144 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,159 @@
11
from __future__ import annotations
22

3+
import dns.resolver
4+
import sys
5+
import json
36
import argparse
47
import socket
5-
from json import dumps as json_dumps
8+
import dataclasses
9+
from typing import TYPE_CHECKING
610

7-
from mcstatus import JavaServer
11+
from mcstatus import JavaServer, BedrockServer
12+
from mcstatus.responses import JavaStatusResponse
13+
from mcstatus.motd import Motd
814

15+
if TYPE_CHECKING:
16+
SupportedServers = JavaServer | BedrockServer
917

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

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

14-
def status(server: JavaServer) -> None:
25+
26+
def _kind(serv: SupportedServers) -> str:
27+
if isinstance(serv, JavaServer):
28+
return "Java"
29+
elif isinstance(serv, BedrockServer):
30+
return "Bedrock"
31+
else:
32+
raise ValueError(f"unsupported server for kind: {serv}")
33+
34+
35+
def _ping_with_fallback(server: SupportedServers) -> float:
36+
# bedrock doesn't have ping method
37+
if isinstance(server, BedrockServer):
38+
return server.status().latency
39+
40+
# try faster ping packet first, falling back to status with a warning.
41+
ping_exc = None
42+
try:
43+
return server.ping(tries=1)
44+
except Exception as e:
45+
ping_exc = e
46+
47+
latency = server.status().latency
48+
49+
address = f"{server.address.host}:{server.address.port}"
50+
print(
51+
f"warning: contacting {address} failed with a 'ping' packet but succeeded with a 'status' packet,\n"
52+
f" this is likely a bug in the server-side implementation.\n"
53+
f' (note: ping packet failed due to "{ping_exc}")\n'
54+
f" for more details, see: https://mcstatus.readthedocs.io/en/stable/pages/faq/\n",
55+
file=sys.stderr,
56+
)
57+
58+
return latency
59+
60+
61+
def ping_cmd(server: SupportedServers) -> int:
62+
print(_ping_with_fallback(server))
63+
return 0
64+
65+
66+
def status_cmd(server: SupportedServers) -> int:
1567
response = server.status()
16-
if response.players.sample is not None:
17-
player_sample = str([f"{player.name} ({player.id})" for player in response.players.sample])
68+
69+
java_res = response if isinstance(response, JavaStatusResponse) else None
70+
71+
if not java_res:
72+
player_sample = ""
73+
elif java_res.players.sample is not None:
74+
player_sample = str([f"{player.name} ({player.id})" for player in java_res.players.sample])
1875
else:
1976
player_sample = "No players online"
2077

21-
print(f"version: v{response.version.name} (protocol {response.version.protocol})")
22-
print(f'motd: "{response.motd}"')
23-
print(f"players: {response.players.online}/{response.players.max} {player_sample}")
78+
if player_sample:
79+
player_sample = " " + player_sample
80+
81+
print(f"version: {_kind(server)} {response.version.name} (protocol {response.version.protocol})")
82+
print(f"motd:{_motd(response.motd)}")
83+
print(f"players: {response.players.online}/{response.players.max}{player_sample}")
84+
print(f"ping: {response.latency:.2f} ms")
85+
return 0
2486

2587

26-
def json(server: JavaServer) -> None:
27-
data = {}
28-
data["online"] = False
29-
# Build data with responses and quit on exception
88+
def json_cmd(server: SupportedServers) -> int:
89+
data = {"online": False, "kind": _kind(server)}
90+
91+
status_res = query_res = exn = None
3092
try:
3193
status_res = server.status(tries=1)
32-
data["version"] = status_res.version.name
33-
data["protocol"] = status_res.version.protocol
34-
data["motd"] = status_res.motd.raw
35-
data["player_count"] = status_res.players.online
36-
data["player_max"] = status_res.players.max
37-
data["players"] = []
38-
if status_res.players.sample is not None:
39-
data["players"] = [{"name": player.name, "id": player.id} for player in status_res.players.sample]
40-
41-
data["ping"] = status_res.latency
42-
data["online"] = True
43-
44-
query_res = server.query(tries=1) # type: ignore[call-arg] # tries is supported with retry decorator
45-
data["host_ip"] = query_res.raw["hostip"]
46-
data["host_port"] = query_res.raw["hostport"]
47-
data["map"] = query_res.map
48-
data["plugins"] = query_res.software.plugins
49-
except Exception: # TODO: Check what this actually excepts
50-
pass
51-
print(json_dumps(data))
52-
53-
54-
def query(server: JavaServer) -> None:
94+
except Exception as e:
95+
exn = exn or e
96+
97+
try:
98+
if isinstance(server, JavaServer):
99+
query_res = server.query(tries=1)
100+
except Exception as e:
101+
exn = exn or e
102+
103+
# construct 'data' dict outside try/except to ensure data processing errors
104+
# are noticed.
105+
data["online"] = bool(status_res or query_res)
106+
if not data["online"]:
107+
assert exn, "server offline but no exception?"
108+
data["error"] = str(exn)
109+
110+
if status_res is not None:
111+
data["status"] = dataclasses.asdict(status_res)
112+
113+
# ensure we are overwriting the motd and not making a new dict field
114+
assert "motd" in data["status"], "motd field missing. has it been renamed?"
115+
data["status"]["motd"] = status_res.motd.simplify().to_minecraft()
116+
117+
if query_res is not None:
118+
# TODO: QueryResponse is not (yet?) a dataclass
119+
data["query"] = qdata = {}
120+
121+
qdata["ip"] = query_res.raw["hostip"]
122+
qdata["port"] = query_res.raw["hostport"]
123+
qdata["map"] = query_res.map
124+
qdata["plugins"] = query_res.software.plugins
125+
qdata["raw"] = query_res.raw
126+
127+
json.dump(data, sys.stdout)
128+
return 0
129+
130+
131+
def query_cmd(server: SupportedServers) -> int:
132+
if not isinstance(server, JavaServer):
133+
print("The 'query' protocol is only supported by Java servers.", file=sys.stderr)
134+
return 1
135+
55136
try:
56137
response = server.query()
57138
except socket.timeout:
58139
print(
59140
"The server did not respond to the query protocol."
60141
"\nPlease ensure that the server has enable-query turned on,"
61142
" and that the necessary port (same as server-port unless query-port is set) is open in any firewall(s)."
62-
"\nSee https://wiki.vg/Query for further information."
143+
"\nSee https://wiki.vg/Query for further information.",
144+
file=sys.stderr,
63145
)
64-
return
146+
return 1
147+
65148
print(f"host: {response.raw['hostip']}:{response.raw['hostport']}")
66-
print(f"software: v{response.software.version} {response.software.brand}")
149+
print(f"software: {_kind(server)} {response.software.version} {response.software.brand}")
150+
print(f"motd:{_motd(response.motd)}")
67151
print(f"plugins: {response.software.plugins}")
68-
print(f'motd: "{response.motd}"')
69152
print(f"players: {response.players.online}/{response.players.max} {response.players.names}")
153+
return 0
70154

71155

72-
def main() -> None:
156+
def main(argv: list[str] = sys.argv[1:]) -> int:
73157
parser = argparse.ArgumentParser(
74158
"mcstatus",
75159
description="""
@@ -80,25 +164,34 @@ def main() -> None:
80164
)
81165

82166
parser.add_argument("address", help="The address of the server.")
167+
parser.add_argument("--bedrock", help="Specifies that 'address' is a Bedrock server (default: Java).", action="store_true")
168+
169+
subparsers = parser.add_subparsers(title="commands", description="Command to run, defaults to 'status'.")
170+
parser.set_defaults(func=status_cmd)
83171

84-
subparsers = parser.add_subparsers()
85-
subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping)
172+
subparsers.add_parser("ping", help="Ping server for latency.").set_defaults(func=ping_cmd)
86173
subparsers.add_parser(
87174
"status", help="Prints server status. Supported by all Minecraft servers that are version 1.7 or higher."
88-
).set_defaults(func=status)
175+
).set_defaults(func=status_cmd)
89176
subparsers.add_parser(
90177
"query", help="Prints detailed server information. Must be enabled in servers' server.properties file."
91-
).set_defaults(func=query)
178+
).set_defaults(func=query_cmd)
92179
subparsers.add_parser(
93180
"json",
94181
help="Prints server status and query in json. Supported by all Minecraft servers that are version 1.7 or higher.",
95-
).set_defaults(func=json)
182+
).set_defaults(func=json_cmd)
96183

97-
args = parser.parse_args()
98-
server = JavaServer.lookup(args.address)
184+
args = parser.parse_args(argv)
185+
lookup = JavaServer.lookup if not args.bedrock else BedrockServer.lookup
99186

100-
args.func(server)
187+
try:
188+
server = lookup(args.address)
189+
return args.func(server)
190+
except (socket.timeout, socket.gaierror, dns.resolver.NoNameservers, ConnectionError) as e:
191+
# catch and hide traceback for expected user-facing errors
192+
print(f"Error: {e}", file=sys.stderr)
193+
return 1
101194

102195

103196
if __name__ == "__main__":
104-
main()
197+
sys.exit(main())

0 commit comments

Comments
 (0)