9 Commits
v0.3.0 ... main

Author SHA1 Message Date
f112760a74 rename commands submodule to subcommands.
add Maprestart.with_configure()
2026-03-25 09:35:09 +00:00
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
80638762d3 patch bump 2026-03-23 20:45:41 +00:00
68f917286f move empty response test into {OutConsole}.print_response() 2026-03-23 20:45:28 +00:00
3062cfa4d8 patch bump 2026-03-23 20:40:13 +00:00
03597b580f don't print empty response 2026-03-23 20:39:59 +00:00
16 changed files with 167 additions and 93 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.0' __version__ = '0.4.0'

View File

@@ -1,33 +1,12 @@
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 .subcommands import Status, Subcommands
Fastrestart,
Gametype,
Hostname,
Map,
Mapname,
Maprestart,
Maprotate,
Plugins,
Status,
)
Subcommands = (
Fastrestart
| Gametype
| Hostname
| Map
| Mapname
| Maprestart
| Maprotate
| Plugins
| Status
)
class Q3rconCli(Command): class Q3rconCli(Command):
@@ -38,6 +17,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 +25,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 +33,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 +46,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 +71,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 +85,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='...'):
@@ -116,6 +109,7 @@ class Q3rconCli(Command):
console.err.print( console.err.print(
f"Timeout waiting for response for command: '{command}'" f"Timeout waiting for response for command: '{command}'"
) )
console.out.print_response(response) console.out.print_response(response)

View File

@@ -1,23 +0,0 @@
from aioq3rcon import Client
from clypi import Command, Spinner, arg
from typing_extensions import override
from q3rcon_cli import console
class Maprestart(Command):
"""Restarts the current map."""
host: str = arg(inherited=True)
port: int = arg(inherited=True)
password: str = arg(inherited=True)
@override
async def run(self):
async with Spinner('Restarting map', suffix='...'):
async with Client(
self.host, self.port, self.password, timeout=3, fragment_read_timeout=1
) as client:
response = await client.send_command('map_restart', interpret=True)
console.out.print_response(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]

View File

@@ -50,8 +50,7 @@ class OutConsole(Console):
return OutConsole.COLOUR_CODE_REGEX.sub('', s) return OutConsole.COLOUR_CODE_REGEX.sub('', s)
def print_response(self, response: str): def print_response(self, response: str):
response = self._remove_colour_codes(response).removeprefix('print\n') if response := self._remove_colour_codes(response).removeprefix('print\n'):
cprint(response, fg=self.style) cprint(response, fg=self.style)
def print_status(self, response: str): def print_status(self, response: str):

View File

@@ -8,14 +8,14 @@ from .maprotate import Maprotate
from .plugins import Plugins from .plugins import Plugins
from .status import Status from .status import Status
__all__ = [ Subcommands = (
'Status', Fastrestart
'Mapname', | Gametype
'Maprotate', | Hostname
'Fastrestart', | Map
'Gametype', | Mapname
'Hostname', | Maprestart
'Map', | Maprotate
'Plugins', | Plugins
'Maprestart', | Status
] )

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.with_configure(self.host, self.port, self.password).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

@@ -0,0 +1,45 @@
from aioq3rcon import Client
from clypi import Command, Spinner, arg
from typing_extensions import override
from q3rcon_cli import config, console
class Maprestart(Command):
"""Restarts the current map."""
host: str = arg(inherited=True)
port: int = arg(inherited=True)
password: str = arg(inherited=True)
@classmethod
def with_configure(cls, host, port, password):
"""Configures the command with the appropriate configuration and runs it.
This classmethod 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.
"""
instance = cls(host, port, password)
(
instance.timeout,
instance.fragment_read_timeout,
instance.interpret,
) = config.get(instance.prog().split()[0].lower())
return instance
@override
async def run(self):
async with Spinner('Restarting map', suffix='...'):
async with Client(
self.host,
self.port,
self.password,
timeout=self.timeout,
fragment_read_timeout=self.fragment_read_timeout,
) as client:
response = await client.send_command(
'map_restart', interpret=self.interpret
)
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)