Compare commits

..

No commits in common. "main" and "v0.2.0" have entirely different histories.
main ... v0.2.0

12 changed files with 176 additions and 675 deletions

View File

@ -30,7 +30,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install "virtualenv<21" hatch pip install hatch
- name: Build package - name: Build package
run: hatch build run: hatch build

View File

@ -1,6 +1,6 @@
# q3rcon tui # q3rcon tui
[![Hatch project](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/pypa/hatch/master/docs/assets/badge/v0.json)](https://github.com/pypa/hatch) [![Hatch project](https://img.shields.io/badge/%F0%9F%A5%9A-Hatch-4051b5.svg)](https://github.com/pypa/hatch)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Version](https://img.shields.io/pypi/v/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/q3rcon-tui.svg)](https://pypi.org/project/q3rcon-tui)
@ -40,15 +40,6 @@ Pass `--host`, `--port` and `--password` as flags:
q3rcon-tui --host=localhost --port=28960 --password=rconpassword q3rcon-tui --host=localhost --port=28960 --password=rconpassword
``` ```
Additional flags:
- `--raw`: Boolean flag, if set the RichLog will print raw responses without rendering tables.
- `--min-status`: Boolean flag, if set the status command will print a minified table.
- note, this will have no effect if in *raw* mode.
- `--append`: Boolean flag, if set the RichLog output will append each response continuously.
- `--version`: Print the version of the TUI.
- `--help`: Print the help message.
#### Environment Variables #### Environment Variables
Store and load from dotenv files located at: Store and load from dotenv files located at:
@ -57,20 +48,13 @@ Store and load from dotenv files located at:
example .env: example .env:
```env ```bash
Q3RCON_TUI_HOST=localhost Q3RCON_TUI_HOST=localhost
Q3RCON_TUI_PORT=28960 Q3RCON_TUI_PORT=28960
Q3RCON_TUI_PASSWORD=rconpassword Q3RCON_TUI_PASSWORD=rconpassword
Q3RCON_TUI_MIN_STATUS=false Q3RCON_TUI_REFRESH_OUTPUT=true
Q3RCON_TUI_RAW=false
Q3RCON_TUI_APPEND=false
``` ```
## Special Thanks
- [lapetus-11](https://github.com/Iapetus-11) for writing the [aio-q3-rcon](https://github.com/Iapetus-11/aio-q3-rcon) package.
- The developers at [Textualize](https://github.com/Textualize) for writing the [textual](https://github.com/Textualize/textual) package.
## License ## License
`q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. `q3rcon-tui` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -17,7 +17,6 @@ classifiers = [
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
] ]

View File

@ -32,8 +32,8 @@ exclude = [
line-length = 88 line-length = 88
indent-width = 4 indent-width = 4
# Assume Python 3.10 # Assume Python 3.9
target-version = "py310" target-version = "py39"
[lint] [lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.

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.7.1' __version__ = '0.2.0'

View File

@ -1,111 +0,0 @@
from typing import Literal
from pydantic import ValidationError
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label, Static
class ConfigScreen(ModalScreen[bool]):
"""Modal dialog for configuring connection settings."""
def __init__(self, tui):
super().__init__()
self._settings = tui._settings
self._original_settings = self._settings.model_copy()
def compose(self) -> ComposeResult:
with Vertical(id='config-dialog'):
yield Static('Connection Configuration', id='config-title')
with Vertical(id='config-form'):
yield Label('Host:')
yield Input(
value=self._original_settings.host,
placeholder='localhost',
id='host-input',
)
yield Label('Port:')
yield Input(
value=str(self._original_settings.port),
placeholder='28960',
id='port-input',
)
yield Label('Password:')
yield Input(
value=self._original_settings.password,
placeholder='Enter password',
password=True,
id='password-input',
)
with Horizontal(id='config-buttons'):
yield Button('Save', variant='success', id='config-save')
yield Button('Cancel', variant='error', id='config-cancel')
def on_button_pressed(self, event: Button.Pressed) -> None:
match event.button.id:
case 'config-save':
self._save_button_handler()
case 'config-cancel':
self._cancel_button_handler()
def _save_button_handler(self):
self._clear_field_errors()
try:
new_host: str = (
self.query_one('#host-input', Input).value.strip() or 'localhost'
)
new_port: int | Literal[28960] = (
self.query_one('#port-input', Input).value or 28960
)
new_password: str = self.query_one('#password-input', Input).value
self._settings.host = new_host
self._settings.port = new_port
self._settings.password = new_password
self.dismiss(True)
except ValidationError as e:
for error in e.errors():
field_name = error.get('loc', ())
error_msg = error.get('msg', str(error))
match field_name:
case ('host',):
self._show_field_error('host-input', error_msg)
case ('port',):
self._show_field_error('port-input', error_msg)
case ('password',):
self._show_field_error('password-input', error_msg)
break # Only show the first error
def _show_field_error(self, field_id: str, error_message: str):
"""Show error message in the specified input field."""
self._clear_field_errors()
field = self.query_one(f'#{field_id}', Input)
field.add_class('error')
field.value = ''
field.placeholder = error_message
field.focus()
self.app.bell()
def _clear_field_errors(self):
"""Clear error styling from all input fields."""
for field_id in ['host-input', 'port-input', 'password-input']:
field = self.query_one(f'#{field_id}', Input)
field.remove_class('error')
match field_id:
case 'host-input':
field.placeholder = 'localhost'
case 'port-input':
field.placeholder = '28960'
case 'password-input':
field.placeholder = 'Enter password'
def _cancel_button_handler(self):
self.dismiss(False)

View File

@ -1,265 +0,0 @@
#dialog {
height: 30;
margin: 4 8;
background: #1e1e2f;
color: #e2e8f0;
border: heavy #3b4252;
border-title-color: #88c0d0;
border-title-style: bold;
padding: 1 3;
grid-size: 3 3;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
align: center middle;
}
#command {
column-span: 3;
height: auto;
width: 1fr;
content-align: center middle;
background: #2e3440;
border: solid #4c566a;
border-title-color: #81a1c1;
padding: 0 1;
margin: 0 1;
}
#command:focus {
border: solid #88c0d0;
background: #3b4252;
}
#response {
column-span: 3;
height: 1fr;
background: #2e3440;
border: solid #4c566a;
border-title-color: #a3be8c;
padding: 1;
margin: 0 1;
scrollbar-background: #3b4252;
scrollbar-color: #5e81ac;
}
Button {
width: 100%;
height: 4;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
text-style: bold;
border: solid #5e81ac;
}
Button:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.success {
background: #a3be8c;
border: solid #a3be8c;
}
Button.success:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.success:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.warning {
background: #ebcb8b;
color: #2e3440;
border: solid #ebcb8b;
}
Button.warning:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.warning:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.error {
background: #bf616a;
border: solid #bf616a;
}
Button.error:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
Button.error:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#send {
background: #a3be8c;
border: solid #a3be8c;
}
#send:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#send:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#config {
background: #ebcb8b;
color: #2e3440;
border: solid #ebcb8b;
}
#config:hover {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#config:focus {
background: #88c0d0;
color: #2e3440;
text-style: bold;
border: solid #88c0d0;
}
#quit {
background: #bf616a;
border: solid #bf616a;
}
#quit:hover {
background: #d08770;
color: #eceff4;
text-style: bold;
border: none;
}
#quit:focus {
background: #d08770;
color: #eceff4;
text-style: bold;
border: none;
}
/* Configuration Dialog Styles */
#config-dialog {
background: #2e3440;
border: heavy #4c566a;
border-title-color: #ebcb8b;
border-title-style: bold;
padding: 1 2;
width: 60;
height: 30;
align: center middle;
}
#config-title {
content-align: center middle;
text-style: bold;
color: #88c0d0;
height: 3;
width: 100%;
background: #3b4252;
margin-bottom: 1;
}
#config-form {
height: auto;
width: 100%;
margin-bottom: 1;
}
#config-form Label {
margin-bottom: 1;
color: #d8dee9;
text-style: bold;
}
#config-form Input {
height: 3;
width: 100%;
margin-bottom: 1;
background: #3b4252;
border: solid #4c566a;
padding: 0 1;
}
#config-form Input:focus {
border: solid #88c0d0;
background: #434c5e;
}
#config-form Input.error {
border: solid #bf616a;
background: #3c2a2a;
color: #d08770;
}
#config-form Input.error:focus {
border: solid #d08770;
background: #4a2c2c;
}
.error-message {
height: 0;
color: #bf616a;
text-style: bold;
margin-top: 0;
margin-bottom: 1;
}
.error-message.visible {
height: 1;
}
#config-buttons {
height: 3;
width: 100%;
align: center middle;
}
#config-buttons Button {
width: 1fr;
margin: 0 1;
}

View File

@ -0,0 +1,84 @@
#dialog {
height: 30;
margin: 4 8;
background: #1e1e2f;
color: #e2e8f0;
border: heavy #3b4252;
border-title-color: #88c0d0;
border-title-style: bold;
padding: 1 3;
grid-size: 2 3;
grid-gutter: 1 2;
grid-rows: auto 1fr auto;
align: center middle;
}
#command {
column-span: 2;
height: auto;
width: 1fr;
content-align: center middle;
background: #2e3440;
border: solid #4c566a;
border-title-color: #81a1c1;
padding: 0 1;
margin: 0 1;
}
#command:focus {
border: solid #88c0d0;
background: #3b4252;
}
#response {
column-span: 2;
height: 1fr;
background: #2e3440;
border: solid #4c566a;
border-title-color: #a3be8c;
padding: 1;
margin: 0 1;
scrollbar-background: #3b4252;
scrollbar-color: #5e81ac;
}
Button {
width: 100%;
height: 3;
margin: 0 1;
background: #5e81ac;
color: #eceff4;
border: none;
text-style: bold;
}
Button:hover {
background: #81a1c1;
text-style: bold;
}
Button.error {
background: #bf616a;
}
Button.error:hover {
background: #d08770;
}
#quit {
background: #bf616a;
border: solid #bf616a;
}
#quit:hover {
background: #d08770;
border: solid #ebcb8b;
}
#send {
background: #a3be8c;
}
#send:hover {
background: #8fbcbb;
}

View File

@ -1,84 +0,0 @@
from pathlib import Path
from typing import Annotated, Type
from pydantic import (
AfterValidator,
AliasChoices,
BeforeValidator,
Field,
)
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from .__about__ import __version__ as version
def version_callback(value: bool) -> bool | None:
if value:
print(f'q3rcon-tui version: {version}')
raise SystemExit(0)
return False
def is_valid_host(host: str) -> str | None:
if not host:
raise ValueError('Host cannot be empty')
return host
def is_valid_port(port: int) -> int | None:
if port < 1 or port > 65535:
raise ValueError('Port must be between 1 and 65535')
return port
def is_valid_password(password: str) -> str | None:
if len(password) < 8:
raise ValueError('Password must be at least 8 characters long')
return password
class Settings(BaseSettings):
host: Annotated[str, AfterValidator(is_valid_host)] = 'localhost'
port: Annotated[int, AfterValidator(is_valid_port)] = 28960
password: Annotated[str, AfterValidator(is_valid_password)] = ''
append: bool = False
min_status: bool = Field(
default=False,
alias='min-status',
validation_alias=AliasChoices(
'min-status',
'Q3RCON_TUI_MIN_STATUS',
),
)
raw: bool = False
version: Annotated[bool, BeforeValidator(version_callback)] = False
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
),
env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_',
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
validate_assignment=True,
frozen=False,
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: ...,
env_settings: ...,
dotenv_settings: ...,
file_secret_settings: ...,
) -> tuple:
return (
CliSettingsSource(settings_cls),
env_settings,
dotenv_settings,
init_settings,
)

View File

@ -1,91 +1,121 @@
import re
from pathlib import Path
from typing import Annotated, Type
from aioq3rcon import Client, RCONError from aioq3rcon import Client, RCONError
from loguru import logger
from pydantic import AfterValidator, BeforeValidator
from pydantic_settings import BaseSettings, CliSettingsSource, SettingsConfigDict
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Grid from textual.containers import Grid
from textual.widgets import Button, Input, RichLog from textual.widgets import Button, Input, RichLog
from .configscreen import ConfigScreen from .__about__ import __version__ as version
from .settings import Settings
from .writable import Writable
class Q3RconTUI(App): def is_valid_password(password: str) -> str | None:
CSS_PATH = 'q3rcon_tui.tcss' if len(password) < 8:
raise ValueError('Password must be at least 8 characters long')
return password
def __init__(self):
super().__init__() def version_callback(value: bool) -> bool | None:
self._settings = Settings() if value:
self.writable = Writable(self) print(f'q3rcon-tui version: {version}')
raise SystemExit(0)
return False
class Settings(BaseSettings):
host: str = 'localhost'
port: int = 28960
password: Annotated[str, AfterValidator(is_valid_password)] = ''
refresh_output: bool = False
version: Annotated[bool, BeforeValidator(version_callback)] = False
model_config = SettingsConfigDict(
env_file=(
'.env',
Path.home() / '.config' / 'q3rcon-tui' / 'config.env',
),
env_file_encoding='utf-8',
env_prefix='Q3RCON_TUI_',
cli_prefix='',
cli_parse_args=True,
cli_implicit_flags=True,
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: ...,
env_settings: ...,
dotenv_settings: ...,
file_secret_settings: ...,
) -> tuple:
return (
CliSettingsSource(settings_cls),
env_settings,
dotenv_settings,
init_settings,
)
try:
settings = Settings()
except ValueError as e:
logger.error(e)
raise SystemExit(1)
class RconApp(App):
RE_COLOR_CODES = re.compile(r'\^[0-9]')
CSS_PATH = 'rcon_tui.tcss'
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Grid( yield Grid(
Input('status', placeholder='Enter a rcon command', id='command'), Input('status', placeholder='Enter a rcon command', id='command'),
RichLog(id='response'), RichLog(id='response'),
Button('Send', variant='success', id='send'), Button('Send', variant='error', id='send'),
Button('Config', variant='warning', id='config'),
Button('Quit', variant='primary', id='quit'), Button('Quit', variant='primary', id='quit'),
id='dialog', id='dialog',
) )
async def on_key(self, event) -> None: async def on_key(self, event) -> None:
# prevent keypresses from ConfigScreen from triggering actions in RconApp if event.key == 'enter':
if self.screen and isinstance(self.screen, ConfigScreen): if self.query_one('#command', Input).has_focus:
return
match event.key:
case 'enter' if self.query_one('#command', Input).has_focus:
self.query_one('#send', Button).press() self.query_one('#send', Button).press()
case 'f2':
self.query_one('#config', Button).press()
async def on_button_pressed(self, event: Button.Pressed) -> None: async def on_button_pressed(self, event: Button.Pressed) -> None:
match event.button.id: if event.button.id == 'quit':
case 'quit':
self._quit_button_handler()
case 'config':
await self._config_button_handler()
case 'send':
await self._send_button_handler()
def _quit_button_handler(self):
self.app.exit() self.app.exit()
async def _config_button_handler(self): if settings.refresh_output:
result = await self.push_screen(ConfigScreen(self))
if result:
self.query_one('#response', RichLog).write(
f'Configuration updated: {self._settings.host}:{self._settings.port}'
)
async def _send_button_handler(self):
if not self._settings.append:
self.query_one('#response', RichLog).clear() self.query_one('#response', RichLog).clear()
cmd = self.query_one('#command', Input).value.strip()
if not cmd:
self.app.bell()
return
try: try:
async with Client( async with Client(
self._settings.host, self._settings.port, self._settings.password settings.host, settings.port, settings.password
) as client: ) as client:
response = await client.send_command(cmd) response = await client.send_command(
self.query_one('#response', RichLog).write( self.query_one('#command', Input).value
self.writable.parse(cmd, response)
)
except RCONError:
output = (
f'Unable to execute command {cmd}.',
'It may be due to a map change or a server restart.',
'If the problem persists, please check your connection settings and ensure the server is running.',
) )
self.query_one('#response', RichLog).write( self.query_one('#response', RichLog).write(
self.writable.error('\n'.join(output)) self.remove_color_codes(response.removeprefix('print\n'))
)
except RCONError as e:
self.query_one('#response', RichLog).write(
f'{type(e).__name__}: Unable to connect to server: is the server running and are the host, port, and password correct? ({e})'
) )
self.query_one('#command', Input).value = '' self.query_one('#command', Input).value = ''
@staticmethod
def remove_color_codes(s: str) -> str:
return RconApp.RE_COLOR_CODES.sub('', s)
def main(): def main():
app = Q3RconTUI() app = RconApp()
app.run() app.run()

View File

@ -1,136 +0,0 @@
import re
from rich.table import Table
from rich.text import Text
Renderable = Text | Table | str
class Writable:
RE_COLOR_CODES = re.compile(r'\^[0-9]')
RE_MAP_FROM_STATUS = re.compile(r'^map: (?P<mapname>mp_[a-z_]+)$')
RE_PLAYER_FROM_STATUS = re.compile(
r'^\s*(?P<slot>[0-9]+)\s+'
r'(?P<score>[0-9-]+)\s+'
r'(?P<ping>[0-9]+)\s+'
r'(?P<guid>[0-9a-f]+)\s+'
r'(?P<name>.*?)\s+'
r'(?P<last>[0-9]+?)\s*'
r'(?P<ip>(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}'
r'(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])):?'
r'(?P<port>-?[0-9]{1,5})\s*'
r'(?P<qport>-?[0-9]{1,5})\s+'
r'(?P<rate>[0-9]+)$',
re.IGNORECASE | re.VERBOSE,
)
RE_CVAR = re.compile(
r'^["](?P<name>[a-z_]+)["]\sis[:]\s'
r'["](?P<value>.*?)["]\s'
r'default[:]\s'
r'["](?P<default>.*?)["]\s'
r'info[:]\s'
r'["](?P<info>.*?)["]$'
)
def __init__(self, tui):
self._settings = tui._settings
@staticmethod
def remove_color_codes(s: str) -> str:
return Writable.RE_COLOR_CODES.sub('', s)
def parse(self, cmd, response: str, style=None) -> Renderable:
response = self.remove_color_codes(response.removeprefix('print\n'))
if self._settings.raw:
return Text(response, style=style)
if response in ['Bad rcon']:
return Text('Incorrect RCON password', style='#c73d4b')
match cmd:
case 'status':
return self.status_table(response)
case _:
if m := self.RE_CVAR.match(response):
return self.cvar_table(m)
else:
return Text(response, style=style)
def error(self, message: str) -> Text:
return Text(message, style='#c73d4b')
def status_table(self, status_response: str) -> Table | str:
table = Table(show_header=True, header_style='bold #88c0d0')
columns = [
('Slot', 'center'),
('Score', 'center'),
('Ping', 'center'),
('GUID', 'center'),
('Name', 'center'),
]
for column, justify in columns:
table.add_column(column, justify=justify)
if not self._settings.min_status:
table.add_column('Last', justify='center')
if self._settings.min_status:
table.add_column('IP', justify='center')
else:
table.add_column('IP:Port', justify='center')
columns = [
('QPort', 'center'),
('Rate', 'center'),
]
if not self._settings.min_status:
for column, justify in columns:
table.add_column(column, justify=justify)
mapname = 'unable to parse map name'
for line in status_response.splitlines():
if m := self.RE_PLAYER_FROM_STATUS.match(line):
name = m.group('name')
if name == '':
name = '[no name]'
row = [
m.group('slot'),
m.group('score'),
m.group('ping'),
m.group('guid'),
name,
]
if self._settings.min_status:
row.append(m.group('ip'))
else:
row.append(f'{m.group("ip")}:{m.group("port")}')
row.append(m.group('last'))
row.append(m.group('qport'))
row.append(m.group('rate'))
table.add_row(*row)
elif m := self.RE_MAP_FROM_STATUS.match(line):
mapname = m.group('mapname')
out = Text(f'Map: {mapname}\n', style='bold #88c0d0')
if len(table.rows) == 0:
return out.append('No players connected', style='#c73d4b')
else:
table.title = out
return table
def cvar_table(self, m: re.Match) -> Table:
table = Table(show_header=True, header_style='bold #88c0d0')
columns = [
('Name', 'center'),
('Value', 'center'),
('Default', 'center'),
('Info', 'center'),
]
for column, justify in columns:
table.add_column(column, justify=justify)
table.add_row(
m.group('name'),
m.group('value'),
m.group('default'),
m.group('info'),
)
return table