refactor: replace configobj spec with pydantic-settings and Click CLI

Drop the config.spec/configobj configuration in favour of an
AppSettings pydantic-settings model (settings.py) loaded from
SMILEYFACE_-prefixed env vars or a .env file. Introduce AppContext
(context.py) to carry settings plus logging, dispatch commands through
a Click CLI (cli.py), and centralise log setup (logging_setup.py).
Update hub_machine, datalayer, and scraping modules to consume the new
context. Add .env.example and ignore .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathew Sir Guest the best 2026-05-30 19:43:38 -06:00
parent 183fa960c3
commit 709e6f25fa
13 changed files with 277 additions and 264 deletions

15
.env.example Normal file

@ -0,0 +1,15 @@
# SmileyFace UT4 Server Configuration
# Copy to .env and fill in your values
SMILEYFACE_PROJECT_DIR=
SMILEYFACE_CONFIG_DIR=
SMILEYFACE_DOWNLOAD_URL=
SMILEYFACE_DOWNLOAD_FILENAME=
SMILEYFACE_DOWNLOAD_MD5=
SMILEYFACE_SKIP_VALIDATE=false
SMILEYFACE_REDIRECT_PROTOCOL=
SMILEYFACE_REDIRECT_URL=
SMILEYFACE_REMOTE_GAME_HOST=
SMILEYFACE_REMOTE_GAME_DIR=
SMILEYFACE_REMOTE_REDIRECT_HOST=
SMILEYFACE_SQLITE_FILENAME=smiles.db

1
.gitignore vendored

@ -4,4 +4,5 @@ dist/
__pycache__
*.egg-info
idea
.env

@ -1,42 +1,4 @@
import logging
import sys
# Module parameters and constants
APP_NAME = "SmileyFace Unreal Tournament 4 Server Panel"
APP_AUTHOR = "Mathew Guest"
APP_VERSION = "0.1.0"
APP_CONFIG_FILENAME = "config.ini"
# config.spec is relative to the module src directory and is the
# config specification (structure, names, and types of config file)
APP_CONFIGSPEC_FILENAME = "config.spec"
# Check and gracefully fail if the user needs to install a 3rd-party dep.
required_lib_names = ["appdirs", "configobj", "colorlog"]
def check_env_has_dependencies(required_lib_names):
"""
Attempts to import each module and gracefully fails if it doesn't
exist.
"""
rc = True
for libname in required_lib_names:
try:
__import__(libname)
except ImportError as ex:
print("missing third-part library: ", ex, file=sys.stderr)
rc = False
except Exception as ex:
print(ex, type(ex))
rc = False
return rc
if not check_env_has_dependencies(required_lib_names):
print("refusing to load program without installed dependencies", file=sys.stderr)
raise ImportError("python environment needs third-party dependencies installed")
# Exposed from sub-modules:
from .app import start_app

@ -1,130 +1,5 @@
import app_skellington
from app_skellington import _util
from . import (
datalayer,
hub_machine,
scrape_latest,
)
class SmileyFace(app_skellington.ApplicationContainer):
def __init__(self, *args, **kwargs):
filename = "config.spec"
self.configspec_filepath = _util.get_asset(__name__, filename)
config_filepath = self._get_config_filepath("smileyface-ut4", "", "hub-config.ini")
super().__init__(
configspec_filepath=self.configspec_filepath,
configini_filepath=config_filepath,
app_name="SmileyFace UT4 Server Panel",
app_author="Mathew Guest",
app_version="0.1",
*args,
**kwargs,
)
def _cli_options(self):
pass
def _command_menu(self):
sm_root = self.cli.init_submenu("command")
_util.register_class_as_commands(self, sm_root, hub_machine.UT4ServerMachine)
sm_scrape = sm_root.create_submenu("scrape")
_util.register_class_as_commands(self, sm_scrape, scrape_latest.ScrapeUt4Pugs)
_util.register_class_as_commands(self, sm_scrape, scrape_latest.ScrapeUtcc)
_util.register_class_as_commands(self, sm_scrape, scrape_latest.LocalFs)
def _services(self):
self["model"] = lambda: hub_machine.UTServerMachine(self.ctx)
self.dal = datalayer.DataLayer(self.ctx)
self["dal"] = lambda: self.dal
self["datalayer"] = lambda: datalayer.DbFuncs(self.ctx, self.dal)
# self['localfs'] = lambda: datalayer.LocalFs(self.ctx, datalayer)
def interactive_shell(self):
pass
def invoke_from_cli(self):
rc = self.load_command()
if not rc:
print("Invalid command. Try -h for usage")
return
# load config
self.invoke_command()
def usage(self):
s = """
Unreal Tournament 4 Server Build and Deploy Script
A list of commands is shown below.
List commands and usage:
./ut4-server-ctl.sh
Show help for a specific command:
./ut4-server-ctl.sh --help <COMMAND>
Here is the list of sub-commands with short syntax reminder:
./ut4-server-ctl.sh 1click-deploy
./ut4-server-ctl.sh clean-instance
./ut4-server-ctl.sh create-directories
./ut4-server-ctl.sh download-linux-server
./ut4-server-ctl.sh download-logs
./ut4-server-ctl.sh generate-instance
./ut4-server-ctl.sh start-server
./ut4-server-ctl.sh stop-server
./ut4-server-ctl.sh upload-redirects
./ut4-server-ctl.sh upload-server
Typical Usage:
1) You need to either configure the remote server hostnames (both game server and remote redirect server)
in the vars files. Edit vars with a text editor, or edit the defaults in this file, or manually type
them interactively when prompted* (coming soon).
e.g.:
PROJECT_DIR="/path/to/this/script/directory/on/local/machine"
REMOTE_GAME_HOST="54.123.456.10"
REMOTE_GAME_DIR="/home/ut4/hub-instance"
REMOTE_REDIRECT_HOST="45.321.654.10"
REMOTE_REDIRECT_DIR="/srv/ut4-redirect/"
2) You need to download the latest Linux Server release from Epic. Do
this with the 'download-server' command.
e.g.:
./ut4-server-ctl.sh download-server
3) Add and configure custom maps, mutators, rulesets, and hub configuration to your *local* project folder.
This is done by modifying the files in the project directory. With the current environment variables,
this is set to be:
"$PROJECT_DIR"
This is the fun part! Connect with UTCC or UTZONE.DE (unaffiliated) to find custom content to put on
your hub.
4) 1click-deploy to update the remote hub and redirect with your latest content. You're done! Rinse and repeat.
e.g.:
./ut4-server-ctl.sh 1click-deploy
Alternatively, this command is equivalent to running the separate commands. If you'd like more fine-grained
control, you can run them individually.
./ut4-server-ctl.sh generate-instance
./ut4-server-ctl.sh upload-redirects
./ut4-server-ctl.sh upload-server
./ut4-server-ctl.sh restart-server
"""
print(s)
from .cli import cli
def start_app():
app = SmileyFace()
app.invoke_from_cli()
cli()

145
smileyface/cli.py Normal file

@ -0,0 +1,145 @@
import click
from smileyface.context import AppContext
from smileyface.logging_setup import setup_logging
from smileyface.settings import AppSettings
def _make_ctx():
"""Build the application context, datalayer, and command instances."""
from smileyface import datalayer, hub_machine, scrape_latest
settings = AppSettings()
setup_logging()
ctx = AppContext(settings)
dal = datalayer.DataLayer(ctx)
db = datalayer.DbFuncs(ctx, dal)
machine = hub_machine.UT4ServerMachine(ctx, db)
scrape_pugs = scrape_latest.ScrapeUt4Pugs(ctx, db)
scrape_utcc = scrape_latest.ScrapeUtcc(ctx, db)
local_fs = scrape_latest.LocalFs(ctx, db)
return machine, scrape_pugs, scrape_utcc, local_fs
@click.group()
def cli():
"""SmileyFace UT4 Server Panel"""
pass
@cli.command("oneclickdeploy")
def oneclickdeploy():
"""Generate instance, upload redirects, and upload server."""
machine, *_ = _make_ctx()
machine.oneclickdeploy()
@cli.command("clean-instance")
def clean_instance():
"""Deletes the generated instance on the local machine."""
machine, *_ = _make_ctx()
machine.clean_instance()
@cli.command("create-directories")
def create_directories():
"""Create required directories for maps, mutators, and config."""
machine, *_ = _make_ctx()
machine.create_directories()
@cli.command("download-linux-server")
def download_linux_server():
"""Download the latest Linux UT4 Server from Epic."""
machine, *_ = _make_ctx()
machine.download_linux_server()
@cli.command("download-logs")
def download_logs():
"""Download the logs from the target hub."""
machine, *_ = _make_ctx()
machine.download_logs()
@cli.command("generate-instance")
def generate_instance():
"""Build local server instance from current configuration."""
machine, *_ = _make_ctx()
machine.generate_instance()
@cli.command("restart-server")
def restart_server():
"""Restart the UT4 server."""
machine, *_ = _make_ctx()
machine.restart_server()
@cli.command("start-server")
def start_server():
"""Start the UT4 server."""
machine, *_ = _make_ctx()
machine.start_server()
@cli.command("stop-server")
def stop_server():
"""Stop the UT4 server."""
machine, *_ = _make_ctx()
machine.stop_server()
@cli.command("upload-redirects")
def upload_redirects():
"""Upload paks to redirect server."""
machine, *_ = _make_ctx()
machine.upload_redirects()
@cli.command("upload-server")
def upload_server():
"""Upload game files to the hub server."""
machine, *_ = _make_ctx()
machine.upload_server()
@cli.group("scrape")
def scrape():
"""Web scraping subcommands."""
pass
@scrape.command("ut4pugs")
def scrape_ut4pugs():
"""Check ut4pugs.us for latest content."""
_, pugs, *_ = _make_ctx()
pugs.check_ut4pugs_for_latest()
@scrape.command("utcc")
def scrape_utcc_cmd():
"""Check utcc.unrealpugs.com for latest content."""
*_, utcc, _ = _make_ctx()
utcc.check_ut4cc_for_latest()
@scrape.command("create-db-table")
def create_db_table():
"""Create database tables."""
*_, local_fs = _make_ctx()
local_fs.create_db_table()
@scrape.command("load-md5s")
def load_md5s():
"""Load MD5 checksums from local pak files."""
*_, local_fs = _make_ctx()
local_fs.load_md5s()
@scrape.command("print-invalid")
def print_invalid():
"""Print pak files that failed validation."""
*_, local_fs = _make_ctx()
local_fs.print_invalid_filepaks()

@ -1,62 +0,0 @@
[app]
project_dir = string(max=255, default='')
config_dir = string(max=255, default='')
download_url = string(max=255, default='https://s3.amazonaws.com/unrealtournament/ShippedBuilds/%2B%2BUT%2BRelease-Next-CL-3525360/UnrealTournament-Server-XAN-3525360-Linux.zip')
download_filename = string(max=255, default='UnrealTournament-Server-XAN-3525360-Linux.zip')
download_md5 = string(max=255, default='cad730ad6793ba6261f9a341ad7396eb')
skip_validate = boolean(default=False)
redirect_protocol = string(max=255, default='')
redirect_url = string(max=255, default='')
remote_game_host = string(max=255, default='')
remote_game_dir = string(max=255, default='')
remote_redirect_host = string(max=255, default='')
sqlite_filename = string(max=255, default='smiles.db')
[logging]
log_file = string(max=255, default='')
log_level = option('critical', 'error', 'warning', 'info', 'debug', default='info')
log_fmt = string(max=255, default='')
disable_existing_loggers = boolean(default=False)
[[formatters]]
[[[colored]]]
() = string(default='colorlog.ColoredFormatter')
format = string(max=255, default='%(log_color)s%(levelname)-8s%(reset)s:%(log_color)s%(name)-5s%(reset)s:%(white)s%(message)s')
[[[basic]]]
() = string(max=255, default='logging.Formatter')
format = string(max=255, default='%(levelname)s:%(name)s:%(asctime)s:%(message)s')
[[[forstorage]]]
() = string(max=255, default='logging.Formatter')
format = string(max=255, default='%(levelname)s:%(name)s:%(asctime)s:%(message)s')
[[handlers]]
[[[stderr]]]
class = string(max=255, default='logging.StreamHandler')
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
formatter = string(max=255, default='colored')
[[[file]]]
class = string(max=255, default='logging.handlers.RotatingFileHandler')
level = option('critical', 'error', 'warning', 'info', 'debug', default='warning')
formatter = string(max=255, default='forstorage')
filename = string(max=255, default='cas_admin.log')
maxBytes = integer(min=0, max=33554432, default=33554432)
backupCount = integer(min=0, max=3, default=1)
[[loggers]]
[[[root]]]
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
handlers = string_list(max=8, default=list('file',))
[[[ut4]]]
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
handlers = string_list(max=8, default=list('stderr',))
propagate = boolean(default=True)
[[[db]]]
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
handlers = string_list(max=8, default=list('stderr',))
propagate = boolean(default=True)

16
smileyface/context.py Normal file

@ -0,0 +1,16 @@
import logging
from smileyface.settings import AppSettings
class AppContext:
def __init__(self, settings: AppSettings):
self.settings = settings
self.log = _LoggerDict()
class _LoggerDict:
"""Dict-like logger access: ctx.log["ut4"] -> logging.getLogger("ut4")"""
def __getitem__(self, name: str) -> logging.Logger:
return logging.getLogger(name)

@ -1,7 +1,7 @@
import os
import sqlite3
import appdirs
import platformdirs
from smileyface import myutil
@ -18,8 +18,8 @@ class DataLayer:
return self._db_conn
def _create_db_connection(self):
local_db_filename = self.ctx.config["app"]["sqlite_filename"]
appdir = appdirs.user_data_dir("smileyface")
local_db_filename = self.ctx.settings.sqlite_filename
appdir = platformdirs.user_data_dir("smileyface")
fullpath = os.path.join(appdir, local_db_filename)
self.ctx.log["ut4"].info("sqlite3 filename: %s", fullpath)

@ -1,8 +1,8 @@
import datetime
import os
import app_skellington._util as apputil
import appdirs
from importlib.resources import files
import sqlparse
from smileyface import myutil, structs
@ -14,8 +14,8 @@ class DbFuncs:
self.dal = dal
def create_tables(self):
sql_filename = apputil.get_asset(__name__, "create_schema.sql")
with open(sql_filename) as fp:
sql_ref = files("smileyface.datalayer").joinpath("create_schema.sql")
with sql_ref.open("r") as fp:
contents_sql = fp.read()
stmts = sqlparse.split(contents_sql)

@ -8,8 +8,6 @@ import subprocess
import sys
import time
import configobj
from . import myutil, structs
from ._util import md5sum_file
from .gameconfig_edit import GameIniSpecial, UnrealIniFile
@ -28,7 +26,7 @@ class UT4ServerMachine:
self.upload_redirects()
self.upload_server()
def clean_instance(self, x):
def clean_instance(self):
"""
Deletes the generated instance on the local machine.
"""
@ -41,7 +39,7 @@ class UT4ServerMachine:
Create required directories which the user installs maps, mutators, and config to.
"""
dirs = ("base", "files/config", "files/maps", "files/mutators", "files/rulesets", "files/unused")
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
if len(project_dir.strip()) == 0:
project_dir = "."
print("project_dir:", project_dir)
@ -51,7 +49,7 @@ class UT4ServerMachine:
cmd = "mkdir -p {}".format(fp)
self._invoke_command(cmd)
def download_linux_server(self, x):
def download_linux_server(self):
"""
Download the latest Linux Unreal Tournament 4 Server from Epic
"""
@ -61,9 +59,9 @@ class UT4ServerMachine:
"""
Download the logs from the target hub.
"""
config_dir = self.ctx.config["app"]["config_dir"]
remote_game_host = self.ctx.config["app"]["remote_game_host"]
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
config_dir = self.ctx.settings.config_dir
remote_game_host = self.ctx.settings.remote_game_host
remote_game_dir = self.ctx.settings.remote_game_dir
self.ctx.log["ut4"].info("Downloading instance logs from target hub.")
cmd = """
@ -88,7 +86,7 @@ ssh {remote_game_host} rm {remote_game_dir}/LinuxServer/UnrealTournament/Saved/L
can be copied to the server.
"""
self.ctx.log["ut4"].info("Generating server instance from custom files...")
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
# rsync
src = "/".join([project_dir, "base/LinuxServer"])
@ -146,10 +144,10 @@ ssh {remote_game_host} {remote_game_dir}/stop-server.sh
"""
self.ctx.log["ut4"].info("Uploading redirects (maps, mutators, etc.) to target hub.")
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
# paks_dir = os.path.join(project_dir, 'instance/LinuxServer/UnrealTournament/Content/Paks/')
paks_dir = os.path.join(project_dir, "files/") # trailing slash required
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
remote_redirect_host = self.ctx.settings.remote_redirect_host
cwd = project_dir
cmd = """
rsync -rivz \
@ -174,8 +172,8 @@ rsync -rivz \
self._redirect_chown()
def _redirect_hide_passwords(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
# (on the server):
gameini = "/srv/ut4-redirect.zavage.net/config/Game.ini"
engineini = "/srv/ut4-redirect.zavage.net/config/Engine.ini"
@ -197,8 +195,8 @@ ssh mathewguest.com \
self._invoke_command(cmd)
def _redirect_upload_script(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
cmd = """
rsync -vz \
{project_dir}/ut4-server-ctl.sh \
@ -213,8 +211,8 @@ rsync -vz \
self._invoke_command(cmd)
def _redirect_chown(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
cmd = """
ssh mathewguest.com \
@ -227,9 +225,9 @@ ssh mathewguest.com \
Upload all required game files to the hub server.
"""
self.ctx.log["ut4"].info("Uploading customized server")
project_dir = self.ctx.config["app"]["project_dir"]
remote_game_host = self.ctx.config["app"]["remote_game_host"]
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
project_dir = self.ctx.settings.project_dir
remote_game_host = self.ctx.settings.remote_game_host
remote_game_dir = self.ctx.settings.remote_game_dir
cwd = None
# transfer #1
@ -304,7 +302,7 @@ ssh {remote_game_host} \
# Make binary executable:
bin_name = "UE4Server-Linux-Shipping"
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
cwd = "{project_dir}/instance/LinuxServer/Engine/Binaries/Linux".format(project_dir=project_dir)
target_file = "{cwd}/{bin_name}".format(cwd=cwd, bin_name=bin_name)
@ -325,8 +323,8 @@ ssh {remote_game_host} \
def _install_config(self):
files = ("Game.ini", "Engine.ini")
project_dir = self.ctx.config["app"]["project_dir"]
config_dir = self.ctx.config["app"]["config_dir"]
project_dir = self.ctx.settings.project_dir
config_dir = self.ctx.settings.config_dir
for fn in files:
self.ctx.log["ut4"].info("Installing file: %s", fn)
src = os.path.join(config_dir, fn)
@ -347,7 +345,7 @@ ssh {remote_game_host} \
ini._config.write(fp)
def _install_paks(self):
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
self.ctx.log["ut4"].info("Installing maps...")
cmd = "rsync -ravzp {src} {dst}".format(
@ -370,9 +368,9 @@ ssh {remote_game_host} \
def _install_redirect_lines(self):
self.ctx.log["ut4"].info("Generating redirect references...")
redirect_protocol = self.ctx.config["app"]["redirect_protocol"]
redirect_url = self.ctx.config["app"]["redirect_url"]
project_dir = self.ctx.config["app"]["project_dir"]
redirect_protocol = self.ctx.settings.redirect_protocol
redirect_url = self.ctx.settings.redirect_url
project_dir = self.ctx.settings.project_dir
mod_dir = "/".join([project_dir, "files"])
game_ini_filepath = "/".join(
@ -415,7 +413,7 @@ ssh {remote_game_host} \
def _install_rulesets(self):
self.ctx.log["ut4"].info("Concatenating rulesets for game modes...")
project_dir = self.ctx.config["app"]["project_dir"]
project_dir = self.ctx.settings.project_dir
src_dir = "/".join([project_dir, "files/rulesets"])
out_dir = "/".join([project_dir, "/instance/LinuxServer/UnrealTournament/Saved/Config/Rulesets"])
@ -464,7 +462,7 @@ ssh {remote_game_host} \
"remote_redirect_host",
)
for name in variable_names:
value = self.ctx.config["app"][name]
value = getattr(self.ctx.settings, name)
self.ctx.log["ut4"].info("%s: %s", name, value)
i = input("Continue with above configuration? (y/N):")

@ -0,0 +1,41 @@
import logging
import logging.config
def setup_logging(log_file: str = ""):
config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"colored": {
"format": "%(levelname)-8s:%(name)-5s:%(message)s",
},
"basic": {
"format": "%(levelname)s:%(name)s:%(asctime)s:%(message)s",
},
},
"handlers": {
"stderr": {
"class": "logging.StreamHandler",
"level": "DEBUG",
"formatter": "colored",
},
},
"loggers": {
"ut4": {"level": "DEBUG", "handlers": ["stderr"], "propagate": True},
"db": {"level": "DEBUG", "handlers": ["stderr"], "propagate": True},
},
"root": {"level": "DEBUG", "handlers": []},
}
if log_file:
config["handlers"]["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"level": "WARNING",
"formatter": "basic",
"filename": log_file,
"maxBytes": 33554432,
"backupCount": 1,
}
config["root"]["handlers"].append("file")
logging.config.dictConfig(config)

@ -13,7 +13,7 @@ class LocalFs:
self.datalayer.create_tables()
def load_md5s(self):
paks_dir = self.ctx.config["app"]["project_dir"]
paks_dir = self.ctx.settings.project_dir
maps_dir = os.path.join(paks_dir, "files", "maps")
print(maps_dir)
self._load_md5_one_dir(maps_dir)

22
smileyface/settings.py Normal file

@ -0,0 +1,22 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="SMILEYFACE_",
env_file=".env",
env_file_encoding="utf-8",
)
project_dir: str = ""
config_dir: str = ""
download_url: str = ""
download_filename: str = ""
download_md5: str = ""
skip_validate: bool = False
redirect_protocol: str = ""
redirect_url: str = ""
remote_game_host: str = ""
remote_game_dir: str = ""
remote_redirect_host: str = ""
sqlite_filename: str = "smiles.db"