4 Commits

Author SHA1 Message Date
d37314f584 minor bump 2026-03-25 08:28:00 +00:00
75c7e0afad add Config class for configuring timings for both interactive and direct modes.
pre_run_hook added, it runs before all subcommands.

gametype command now calls maprestart command directly if the -f flag is passed.
2026-03-25 08:27:48 +00:00
89539ae817 mv CMD_CONFIG 2026-03-24 15:56:56 +00:00
305af813b8 implement Connection flag parsing
add Shell Completion section to README

patch bump
2026-03-23 23:30:29 +00:00
10 changed files with 130 additions and 36 deletions

View File

@@ -10,6 +10,9 @@
## Table of Contents ## Table of Contents
- [Installation](#installation) - [Installation](#installation)
- [Configuration](#configuration)
- [Use](#use)
- [Shell Completion](#shell-completion)
- [License](#license) - [License](#license)
## Installation ## Installation
@@ -60,7 +63,7 @@ export Q3RCON_CLI_PASSWORD="<rcon password>"
Usage: q3rcon-cli [OPTIONS] COMMAND Usage: q3rcon-cli [OPTIONS] COMMAND
┏━ Subcommands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┏━ Subcommands ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ fastrestart Executes a fast restart of the server ┃ fastrestart Executes a fast restart of the map
┃ gametype Get or set the current gametype of the server ┃ ┃ gametype Get or set the current gametype of the server ┃
┃ hostname Get or set the current hostname of the server ┃ ┃ hostname Get or set the current hostname of the server ┃
┃ map Get the current map or change to a new one ┃ ┃ map Get the current map or change to a new one ┃
@@ -83,6 +86,14 @@ Usage: q3rcon-cli [OPTIONS] COMMAND
┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
``` ```
## Shell Completion
Shell completion scripts are available for *bash*, *zsh*, and *fish*.
```console
q3rcon-cli --install-autocomplete
```
## Special Thanks ## Special Thanks
- [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package. - [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package.

View File

@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online> # SPDX-FileCopyrightText: 2026-present onyx-and-iris <code@onyxandiris.online>
# #
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
__version__ = '0.3.2' __version__ = '0.4.0'

View File

@@ -1,9 +1,10 @@
import clypi import clypi
from aioq3rcon import Client, IncorrectPasswordError from aioq3rcon import Client, IncorrectPasswordError
from clypi import Command, Spinner, arg from clypi import Command, Spinner, arg
from clypi import parsers as cp
from typing_extensions import override from typing_extensions import override
from . import console from . import config, console
from .__about__ import __version__ from .__about__ import __version__
from .commands import ( from .commands import (
Fastrestart, Fastrestart,
@@ -38,6 +39,7 @@ class Q3rconCli(Command):
help='The host to connect to', help='The host to connect to',
env='Q3RCON_CLI_HOST', env='Q3RCON_CLI_HOST',
group='Connection', group='Connection',
parser=cp.Str(min=1),
) )
port: int = arg( port: int = arg(
28960, 28960,
@@ -45,6 +47,7 @@ class Q3rconCli(Command):
help='The port to connect to', help='The port to connect to',
env='Q3RCON_CLI_PORT', env='Q3RCON_CLI_PORT',
group='Connection', group='Connection',
parser=cp.Int(min=1, max=65535),
) )
password: str = arg( password: str = arg(
'', '',
@@ -52,6 +55,7 @@ class Q3rconCli(Command):
help='The password for authentication', help='The password for authentication',
env='Q3RCON_CLI_PASSWORD', env='Q3RCON_CLI_PASSWORD',
group='Connection', group='Connection',
parser=cp.Str(min=8),
) )
interactive: bool = arg( interactive: bool = arg(
False, False,
@@ -64,6 +68,23 @@ class Q3rconCli(Command):
help='Show the version and exit', help='Show the version and exit',
) )
@override
async def pre_run_hook(self):
if self.subcommand is not None and self.interactive:
console.err.print(
'Cannot use subcommands in interactive mode.',
style='yellow',
)
self.print_help()
raise SystemExit(1)
if self.subcommand:
(
self.subcommand.timeout,
self.subcommand.fragment_read_timeout,
self.subcommand.interpret,
) = config.get(self.subcommand.prog().split()[0].lower())
@override @override
async def run(self): async def run(self):
if self.version: if self.version:
@@ -72,8 +93,13 @@ class Q3rconCli(Command):
if self.interactive: if self.interactive:
await self.run_interactive() await self.run_interactive()
else: return
if self.subcommand is None:
await Status(self.host, self.port, self.password).run() await Status(self.host, self.port, self.password).run()
return
self.print_help()
async def run_interactive(self): async def run_interactive(self):
print( print(
@@ -81,23 +107,12 @@ class Q3rconCli(Command):
clypi.style("'Q'", fg='yellow'), clypi.style("'Q'", fg='yellow'),
clypi.style('to quit.', fg='blue'), clypi.style('to quit.', fg='blue'),
) )
DEFAULT_TIMEOUT = 2
DEFAULT_FRAGMENT_READ_TIMEOUT = 0.25
while command := input(clypi.style('cmd: ', fg='green')): while command := input(clypi.style('cmd: ', fg='green')):
if command.lower() == 'q': if command.lower() == 'q':
break break
CMD_CONFIG = { timeout, fragment_read_timeout, interpret = config.get(
'status': (2, 1, False), command.split()[0].lower()
'fast_restart': (3, 1, True),
'map_restart': (3, 1, True),
'map': (3, 1, True),
'map_rotate': (3, 1, True),
}
timeout, fragment_read_timeout, interpret = CMD_CONFIG.get(
command.split()[0].lower(),
(DEFAULT_TIMEOUT, DEFAULT_FRAGMENT_READ_TIMEOUT, False),
) )
async with Spinner(f"Sending command: '{command}'", suffix='...'): async with Spinner(f"Sending command: '{command}'", suffix='...'):

View File

@@ -6,7 +6,7 @@ from q3rcon_cli import console
class Fastrestart(Command): class Fastrestart(Command):
"""Executes a fast restart of the server.""" """Executes a fast restart of the map."""
host: str = arg(inherited=True) host: str = arg(inherited=True)
port: int = arg(inherited=True) port: int = arg(inherited=True)
@@ -15,7 +15,15 @@ class Fastrestart(Command):
@override @override
async def run(self): async def run(self):
async with Spinner('Executing fast restart', suffix='...'): async with Spinner('Executing fast restart', suffix='...'):
async with Client(self.host, self.port, self.password) as client: async with Client(
response = await client.send_command('fast_restart', interpret=True) self.host,
self.port,
self.password,
timeout=self.timeout,
fragment_read_timeout=self.fragment_read_timeout,
) as client:
response = await client.send_command(
'fast_restart', interpret=self.interpret
)
console.out.print_response(response) console.out.print_response(response)

View File

@@ -4,6 +4,8 @@ from typing_extensions import override
from q3rcon_cli import console from q3rcon_cli import console
from .maprestart import Maprestart
class Gametype(Command): class Gametype(Command):
"""Get or set the current gametype of the server.""" """Get or set the current gametype of the server."""
@@ -35,11 +37,7 @@ class Gametype(Command):
await client.send_command(f'g_gametype {self.new_gametype}') await client.send_command(f'g_gametype {self.new_gametype}')
if self.force: if self.force:
async with Spinner('Forcing gametype change', suffix='...'): await Maprestart(self.host, self.port, self.password).configure_and_run()
async with Client(self.host, self.port, self.password) as client:
client.timeout = 3
client.fragment_read_timeout = 1
await client.send_command('map_restart')
console.out.print( console.out.print(
f'Gametype changed successfully to {self.new_gametype}.', style='green' f'Gametype changed successfully to {self.new_gametype}.', style='green'

View File

@@ -27,9 +27,16 @@ class Map(Command):
async with Spinner('Changing map', suffix='...'): async with Spinner('Changing map', suffix='...'):
async with Client( async with Client(
self.host, self.port, self.password, fragment_read_timeout=1 self.host,
self.port,
self.password,
timeout=self.timeout,
fragment_read_timeout=self.fragment_read_timeout,
) as client: ) as client:
await client.send_command(f'map mp_{self.new_map.removeprefix("mp_")}') await client.send_command(
f'map mp_{self.new_map.removeprefix("mp_")}',
interpret=self.interpret,
)
console.out.print( console.out.print(
f'Map changed to {self.new_map.removeprefix("mp_")}', style='green' f'Map changed to {self.new_map.removeprefix("mp_")}', style='green'

View File

@@ -2,7 +2,7 @@ from aioq3rcon import Client
from clypi import Command, Spinner, arg from clypi import Command, Spinner, arg
from typing_extensions import override from typing_extensions import override
from q3rcon_cli import console from q3rcon_cli import config, console
class Maprestart(Command): class Maprestart(Command):
@@ -12,12 +12,32 @@ class Maprestart(Command):
port: int = arg(inherited=True) port: int = arg(inherited=True)
password: str = arg(inherited=True) password: str = arg(inherited=True)
async def configure_and_run(self):
"""Configures the command with the appropriate configuration and runs it.
This method is used if we invoke the maprestart command from another command (e.g. gametype),
since the pre_run_hook is not called in that case.
"""
(
self.timeout,
self.fragment_read_timeout,
self.interpret,
) = config.get(self.prog().split()[0].lower())
await self.run()
@override @override
async def run(self): async def run(self):
async with Spinner('Restarting map', suffix='...'): async with Spinner('Restarting map', suffix='...'):
async with Client( async with Client(
self.host, self.port, self.password, timeout=3, fragment_read_timeout=1 self.host,
self.port,
self.password,
timeout=self.timeout,
fragment_read_timeout=self.fragment_read_timeout,
) as client: ) as client:
response = await client.send_command('map_restart', interpret=True) response = await client.send_command(
'map_restart', interpret=self.interpret
)
console.out.print_response(response) console.out.print_response(response)

View File

@@ -19,9 +19,11 @@ class Maprotate(Command):
self.host, self.host,
self.port, self.port,
self.password, self.password,
timeout=3, timeout=self.timeout,
fragment_read_timeout=1, fragment_read_timeout=self.fragment_read_timeout,
) as client: ) as client:
response = await client.send_command('map_rotate', interpret=True) response = await client.send_command(
'map_rotate', interpret=self.interpret
)
console.out.print_response(response) console.out.print_response(response)

View File

@@ -15,9 +15,7 @@ class Status(Command):
@override @override
async def run(self): async def run(self):
async with Spinner('Fetching status', suffix='...'): async with Spinner('Fetching status', suffix='...'):
async with Client( async with Client(self.host, self.port, self.password) as client:
self.host, self.port, self.password, fragment_read_timeout=1
) as client:
response = await client.send_command('status') response = await client.send_command('status')
console.out.print_status(response) console.out.print_status(response)

35
src/q3rcon_cli/config.py Normal file
View File

@@ -0,0 +1,35 @@
from collections import UserDict
class Config(UserDict):
DEFAULT_TIMEOUT: int = 2
DEFAULT_FRAGMENT_READ_TIMEOUT: float = 0.25
def __init__(self):
self.data = {
'status': (2, 0.25, False),
'fast_restart': (3, 1, True),
'map_restart': (3, 1, True),
'map': (3, 1, True),
'map_rotate': (3, 1, True),
}
def __getitem__(self, key):
return self.data.get(
key, (self.DEFAULT_TIMEOUT, self.DEFAULT_FRAGMENT_READ_TIMEOUT, False)
)
_config = Config()
def get(key: str):
match key:
case 'fast_restart' | 'fastrestart':
return _config['fast_restart']
case 'map_restart' | 'maprestart':
return _config['map_restart']
case 'map_rotate' | 'maprotate':
return _config['map_rotate']
case _:
return _config[key]