Wired up server config.yaml functionality

This commit is contained in:
Eric Froemling 2020-04-30 00:08:23 -07:00
parent b42fc09ffc
commit 48f72ec123
12 changed files with 349 additions and 18 deletions

View File

@ -165,6 +165,7 @@
<w>bgthread</w>
<w>bhval</w>
<w>binc</w>
<w>bincfg</w>
<w>bindcode</w>
<w>bindvals</w>
<w>bisectmodule</w>
@ -512,6 +513,7 @@
<w>efrotool</w>
<w>efrotools</w>
<w>eftools</w>
<w>efxjtp</w>
<w>eids</w>
<w>elementtree</w>
<w>elim</w>
@ -588,6 +590,8 @@
<w>fhashes</w>
<w>fhdr</w>
<w>fieldattr</w>
<w>fieldsdict</w>
<w>fieldtype</w>
<w>fieldtypes</w>
<w>filebytes</w>
<w>filecmp</w>
@ -941,6 +945,7 @@
<w>lazybuild</w>
<w>lazybuilddir</w>
<w>lbits</w>
<w>lbld</w>
<w>lcfg</w>
<w>lcolor</w>
<w>lcrypto</w>
@ -1208,13 +1213,16 @@
<w>objs</w>
<w>objt</w>
<w>objtype</w>
<w>obval</w>
<w>occurrances</w>
<w>oculus</w>
<w>offsanchor</w>
<w>ofval</w>
<w>oggenc</w>
<w>oghash</w>
<w>oghashes</w>
<w>ogval</w>
<w>oival</w>
<w>oldlady</w>
<w>onln</w>
<w>onscreencountdown</w>
@ -1234,6 +1242,7 @@
<w>osascript</w>
<w>osmusic</w>
<w>ostype</w>
<w>osval</w>
<w>otherplayer</w>
<w>otherspawn</w>
<w>ourself</w>
@ -1473,6 +1482,8 @@
<w>representer</w>
<w>reprlib</w>
<w>reqs</w>
<w>reqtype</w>
<w>reqtypes</w>
<w>resample</w>
<w>resourcetypeinfo</w>
<w>respawn</w>
@ -1573,6 +1584,7 @@
<w>shiftposition</w>
<w>shouldn</w>
<w>showpoints</w>
<w>showstats</w>
<w>showsubseconds</w>
<w>shroom</w>
<w>shutil</w>
@ -1720,6 +1732,7 @@
<w>tbutton</w>
<w>tcall</w>
<w>tchar</w>
<w>tclass</w>
<w>tcombine</w>
<w>tdelay</w>
<w>tdval</w>
@ -1833,6 +1846,7 @@
<w>txtval</w>
<w>txtw</w>
<w>typeargs</w>
<w>typecheck</w>
<w>typechecker</w>
<w>typedval</w>
<w>typeshed</w>

View File

@ -596,8 +596,12 @@ test-assetmanager:
@tools/snippets pytest -o log_cli=true -o log_cli_level=debug -s -v \
tests/test_ba/test_assetmanager.py::test_assetmanager
test-dataclassutils:
@tools/snippets pytest -o log_cli=true -o log_cli_level=debug -s -v \
tests/test_efro/test_dataclassutils.py
# Tell make which of these targets don't represent files.
.PHONY: test test-full
.PHONY: test test-full test-assetmanager
################################################################################

View File

@ -453,9 +453,11 @@
"ba_data/python/bastd/ui/watch.py",
"ba_data/python/efro/__init__.py",
"ba_data/python/efro/__pycache__/__init__.cpython-37.opt-1.pyc",
"ba_data/python/efro/__pycache__/dataclassutils.cpython-37.opt-1.pyc",
"ba_data/python/efro/__pycache__/executils.cpython-37.opt-1.pyc",
"ba_data/python/efro/__pycache__/jsonutils.cpython-37.opt-1.pyc",
"ba_data/python/efro/__pycache__/util.cpython-37.opt-1.pyc",
"ba_data/python/efro/dataclassutils.py",
"ba_data/python/efro/entity/__init__.py",
"ba_data/python/efro/entity/__pycache__/__init__.cpython-37.opt-1.pyc",
"ba_data/python/efro/entity/__pycache__/_base.cpython-37.opt-1.pyc",

View File

@ -144,6 +144,7 @@ ASSET_TARGETS_WIN_X64 += $(EXTRAS_TARGETS_WIN_X64)
SCRIPT_TARGETS_PY_1 = \
build/server/ballisticacore_server.py \
build/ba_data/python/efro/executils.py \
build/ba_data/python/efro/dataclassutils.py \
build/ba_data/python/efro/util.py \
build/ba_data/python/efro/__init__.py \
build/ba_data/python/efro/jsonutils.py \
@ -384,6 +385,7 @@ SCRIPT_TARGETS_PY_1 = \
SCRIPT_TARGETS_PYC_1 = \
build/server/__pycache__/ballisticacore_server.cpython-37.opt-1.pyc \
build/ba_data/python/efro/__pycache__/executils.cpython-37.opt-1.pyc \
build/ba_data/python/efro/__pycache__/dataclassutils.cpython-37.opt-1.pyc \
build/ba_data/python/efro/__pycache__/util.cpython-37.opt-1.pyc \
build/ba_data/python/efro/__pycache__/__init__.cpython-37.opt-1.pyc \
build/ba_data/python/efro/__pycache__/jsonutils.cpython-37.opt-1.pyc \
@ -644,6 +646,11 @@ build/ba_data/python/efro/__pycache__/executils.cpython-37.opt-1.pyc: \
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/efro/__pycache__/dataclassutils.cpython-37.opt-1.pyc: \
build/ba_data/python/efro/dataclassutils.py
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/efro/__pycache__/util.cpython-37.opt-1.pyc: \
build/ba_data/python/efro/util.py
@echo Compiling script: $^

View File

@ -104,8 +104,8 @@ class ServerConfig:
# this to provide a convenient in-game link to it in the server-browser
# beside the server name.
# if ${ACCOUNT} is present in the string, it will be replaced by the
# currently-signed-in account's id. To get info about an account,
# you can use the following url:
# currently-signed-in account's id. To fetch info about an account,
# your backend server can use the following url:
# http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE
stats_url: Optional[str] = None

View File

@ -0,0 +1,116 @@
# Copyright (c) 2011-2020 Eric Froemling
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# -----------------------------------------------------------------------------
"""Custom functionality for dealing with dataclasses."""
from __future__ import annotations
import dataclasses
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Any, Dict, Type, Tuple
# For fields with these type strings, we require a passed value's type
# to exactly match one of the tuple values to consider the assignment valid.
_ASSIGN_TYPES: Dict[str, Tuple[Type, ...]] = {
'bool': (bool, ),
'str': (str, ),
'int': (int, ),
'float': (float, ),
'Optional[int]': (int, type(None)),
'Optional[str]': (str, type(None)),
'Optional[bool]': (bool, type(None)),
'Optional[float]': (float, type(None)),
}
def dataclass_assign(instance: Any, values: Dict[str, Any]) -> None:
"""Safely assign values from a dict to a dataclass instance.
A TypeError will be raised if types to not match the dataclass fields
or are unsupported by this function. Note that a limited number of
types are supported. More can be added as needed.
Exact types are strictly checked, so a bool cannot be passed for
an int field, an int can't be passed for a float, etc.
(can reexamine this strictness if it proves to be a problem)
An AttributeError will be raised if attributes are passed which are
not present on the dataclass as fields.
This function may be significantly slower than simply passing dict
values to a dataclass' constructor or other more direct methods, but
the increased safety checks may be worth the extra overhead in some
cases.
"""
if not dataclasses.is_dataclass(instance):
raise TypeError(f'Passed instance {instance} is not a dataclass.')
if not isinstance(values, dict):
raise TypeError("Expected a dict for 'values' arg.")
fields = dataclasses.fields(instance)
fieldsdict = {f.name: f for f in fields}
for key, value in values.items():
if key not in fieldsdict:
raise AttributeError(f"'{type(instance).__name__}' dataclass has"
f" no '{key}' field.")
field = fieldsdict[key]
# We expect to be operating with 'from __future__ import annotations'
# so this should always be a string for us; not an actual type.
# Complain if we come across an actual type.
fieldtype: str = field.type # type: ignore
if not isinstance(fieldtype, str):
raise RuntimeError(
f'Dataclass {type(instance).__name__} seems to have'
f' been created without "from __future__ import annotations";'
f' those dataclasses are unsupported here.')
reqtypes = _ASSIGN_TYPES.get(fieldtype)
if reqtypes is not None:
# pylint: disable=unidiomatic-typecheck
if not any(type(value) is t for t in reqtypes):
# if not isinstance(value, reqtype):
if len(reqtypes) == 1:
expected = reqtypes[0].__name__
else:
names = ', '.join(t.__name__ for t in reqtypes)
expected = f'Union[{names}]'
raise TypeError(f'Invalid value type for "{key}";'
f' expected "{expected}", got'
f' "{type(value).__name__}".')
else:
raise TypeError(f'Field type "{fieldtype}" is unsupported here.')
# Ok, if we made it here, the value is kosher. Do the assign.
setattr(instance, key, value)
def dataclass_validate(instance: Any) -> None:
"""Ensure values in a dataclass are correct types.
Note that this will always fail if a dataclass has value types
unsupported by this module.
"""
# We currently simply operate by grabbing dataclass values as a dict
# and passing them through dataclass_assign().
# It would be possible to write slightly more efficient custom code,
# this, but this keeps things simple and will allow us to easily
# incorporate things like value coercion later if we add that.
dataclass_assign(instance, dataclasses.asdict(instance))

View File

@ -30,8 +30,8 @@ if TYPE_CHECKING:
from typing import Any
# Special attr we included for our extended type information
# (extended-json-type)
TYPE_TAG = '_xjtp'
# (efro-extended-json-type)
TYPE_TAG = '_efxjtp'
_pytz_utc: Any

View File

@ -31,8 +31,8 @@ import time
from pathlib import Path
from typing import TYPE_CHECKING
# We make use of the bacommon package and site-packages included
# with our bundled Ballistica dist.
# We make use of the bacommon and efro packages as well as site-packages
# included with our bundled Ballistica dist.
sys.path += [
str(Path(os.getcwd(), 'dist', 'ba_data', 'python')),
str(Path(os.getcwd(), 'dist', 'ba_data', 'python-site-packages'))
@ -40,6 +40,7 @@ sys.path += [
from bacommon.servermanager import (ServerConfig, ServerCommand,
make_server_command)
from efro.dataclassutils import dataclass_assign
if TYPE_CHECKING:
from typing import Optional, List, Dict
@ -55,13 +56,14 @@ class ServerManagerApp:
def __init__(self) -> None:
# We actually run from the 'dist' subdir.
self._config = self._load_config()
# We actually operate from the 'dist' subdir.
if not os.path.isdir('dist'):
raise RuntimeError('"dist" directory not found.')
os.chdir('dist')
self._binary_path = self._get_binary_path()
self._config = ServerConfig()
self._binary_commands: List[str] = []
self._binary_commands_lock = threading.Lock()
@ -75,6 +77,31 @@ class ServerManagerApp:
self._process: Optional[subprocess.Popen[bytes]] = None
self._process_launch_time: Optional[float] = None
@property
def config(self) -> ServerConfig:
"""The current config settings for the app."""
return self._config
def _load_config(self) -> ServerConfig:
user_config_path = 'config.yaml'
# Start with a default config, and if there is a config.yaml,
# override parts of it.
config = ServerConfig()
if os.path.exists(user_config_path):
import yaml
with open(user_config_path) as infile:
user_config = yaml.safe_load(infile.read())
dataclass_assign(config, user_config)
# An empty config file will yield None, and that's ok.
if user_config is not None:
if not isinstance(user_config, dict):
raise RuntimeError(f'Invalid config format; expected dict,'
f' got {type(user_config)}.')
return config
def _get_binary_path(self) -> str:
"""Locate the game binary that we'll use."""
if os.name == 'nt':
@ -215,15 +242,15 @@ class ServerManagerApp:
os.makedirs('ba_root', exist_ok=True)
if os.path.exists('ba_root/config.json'):
with open('ba_root/config.json') as infile:
bacfg = json.loads(infile.read())
bincfg = json.loads(infile.read())
else:
bacfg = {}
bacfg['Port'] = self._config.port
bacfg['Enable Telnet'] = self._config.enable_telnet
bacfg['Telnet Port'] = self._config.telnet_port
bacfg['Telnet Password'] = self._config.telnet_password
bincfg = {}
bincfg['Port'] = self._config.port
bincfg['Enable Telnet'] = self._config.enable_telnet
bincfg['Telnet Port'] = self._config.telnet_port
bincfg['Telnet Password'] = self._config.telnet_password
with open('ba_root/config.json', 'w') as outfile:
outfile.write(json.dumps(bacfg))
outfile.write(json.dumps(bincfg))
def _run_process_until_exit(self) -> None:
assert self._process is not None

View File

@ -1,5 +1,5 @@
<!-- THIS FILE IS AUTO GENERATED; DO NOT EDIT BY HAND -->
<h4><em>last updated on 2020-04-29 for Ballistica version 1.5.0 build 20001</em></h4>
<h4><em>last updated on 2020-04-30 for Ballistica version 1.5.0 build 20001</em></h4>
<p>This page documents the Python classes and functions in the 'ba' module,
which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please <a href="mailto:support@froemling.net">let me know</a>. Happy modding!</p>
<hr>

View File

@ -0,0 +1,155 @@
# Copyright (c) 2011-2020 Eric Froemling
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# -----------------------------------------------------------------------------
"""Testing dataclassutils functionality."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import pytest
from efro.dataclassutils import dataclass_assign, dataclass_validate
if TYPE_CHECKING:
from typing import Optional
def test_assign() -> None:
"""Testing various assignments."""
@dataclass
class _TestClass:
ival: int = 0
sval: str = ''
bval: bool = True
fval: float = 1.0
oival: Optional[int] = None
osval: Optional[str] = None
obval: Optional[bool] = None
ofval: Optional[float] = None
tclass = _TestClass()
class _TestClass2:
pass
tclass2 = _TestClass2()
# Arg types:
with pytest.raises(TypeError):
dataclass_assign(tclass2, {})
with pytest.raises(TypeError):
dataclass_assign(tclass, []) # type: ignore
# Invalid attrs.
with pytest.raises(AttributeError):
dataclass_assign(tclass, {'nonexistent': 'foo'})
# Correct types.
dataclass_assign(tclass, {
'ival': 1,
'sval': 'foo',
'bval': True,
'fval': 2.0,
})
dataclass_assign(tclass, {
'oival': None,
'osval': None,
'obval': None,
'ofval': None,
})
dataclass_assign(tclass, {
'oival': 1,
'osval': 'foo',
'obval': True,
'ofval': 2.0,
})
# Type mismatches.
with pytest.raises(TypeError):
dataclass_assign(tclass, {'ival': 'foo'})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'sval': 1})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'bval': 2})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'oival': 'foo'})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'osval': 1})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'obval': 2})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'ofval': 'blah'})
# More subtle ones (we currently require EXACT type matches)
with pytest.raises(TypeError):
dataclass_assign(tclass, {'ival': True})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'fval': 2})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'bval': 1})
with pytest.raises(TypeError):
dataclass_assign(tclass, {'ofval': 1})
def test_validate() -> None:
"""Testing validation."""
@dataclass
class _TestClass:
ival: int = 0
sval: str = ''
bval: bool = True
fval: float = 1.0
oival: Optional[int] = None
osval: Optional[str] = None
obval: Optional[bool] = None
ofval: Optional[float] = None
# Should pass by default.
tclass = _TestClass()
dataclass_validate(tclass)
# No longer valid.
tclass.fval = 1
with pytest.raises(TypeError):
dataclass_validate(tclass)
# Should pass by default.
tclass = _TestClass()
dataclass_validate(tclass)
# No longer valid.
# noinspection PyTypeHints
tclass.ival = None # type: ignore
with pytest.raises(TypeError):
dataclass_validate(tclass)

View File

@ -615,11 +615,17 @@ def _get_server_config_template_yaml() -> str:
assert veq == '='
vval = eval(vval_raw) # pylint: disable=eval-used
# Override a few specifics:
# Filter/override a few things.
if vname == 'playlist_code':
# User wouldn't want to pass the default of None here.
vval = 12345
elif vname == 'stats_url':
vval = ('https://mystatssite.com/'
'showstats?player=${ACCOUNT}')
lines_out.append('#' + yaml.dump({vname: vval}).strip())
else:
# Convert comments referring to python bools to yaml bools.
line = line.replace('True', 'true').replace('False', 'false')
lines_out.append(line)
return '\n'.join(lines_out)