From 48f72ec12338c1df48dc8004d41d229d8c9d139f Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Thu, 30 Apr 2020 00:08:23 -0700 Subject: [PATCH] Wired up server config.yaml functionality --- .idea/dictionaries/ericf.xml | 14 ++ Makefile | 6 +- assets/.asset_manifest_1.json | 2 + assets/Makefile | 7 + .../ba_data/python/bacommon/servermanager.py | 4 +- .../src/ba_data/python/efro/dataclassutils.py | 116 +++++++++++++ assets/src/ba_data/python/efro/jsonutils.py | 4 +- assets/src/server/ballisticacore_server.py | 49 ++++-- docs/ba_module.md | 2 +- tests/test_efro/test_dataclassutils.py | 155 ++++++++++++++++++ .../{test_entities.py => test_entity.py} | 0 tools/batools/build.py | 8 +- 12 files changed, 349 insertions(+), 18 deletions(-) create mode 100644 assets/src/ba_data/python/efro/dataclassutils.py create mode 100644 tests/test_efro/test_dataclassutils.py rename tests/test_efro/{test_entities.py => test_entity.py} (100%) diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index 0d21e039..034388dd 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -165,6 +165,7 @@ bgthread bhval binc + bincfg bindcode bindvals bisectmodule @@ -512,6 +513,7 @@ efrotool efrotools eftools + efxjtp eids elementtree elim @@ -588,6 +590,8 @@ fhashes fhdr fieldattr + fieldsdict + fieldtype fieldtypes filebytes filecmp @@ -941,6 +945,7 @@ lazybuild lazybuilddir lbits + lbld lcfg lcolor lcrypto @@ -1208,13 +1213,16 @@ objs objt objtype + obval occurrances oculus offsanchor + ofval oggenc oghash oghashes ogval + oival oldlady onln onscreencountdown @@ -1234,6 +1242,7 @@ osascript osmusic ostype + osval otherplayer otherspawn ourself @@ -1473,6 +1482,8 @@ representer reprlib reqs + reqtype + reqtypes resample resourcetypeinfo respawn @@ -1573,6 +1584,7 @@ shiftposition shouldn showpoints + showstats showsubseconds shroom shutil @@ -1720,6 +1732,7 @@ tbutton tcall tchar + tclass tcombine tdelay tdval @@ -1833,6 +1846,7 @@ txtval txtw typeargs + typecheck typechecker typedval typeshed diff --git a/Makefile b/Makefile index b0a25ee2..0fec9ed1 100644 --- a/Makefile +++ b/Makefile @@ -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 ################################################################################ diff --git a/assets/.asset_manifest_1.json b/assets/.asset_manifest_1.json index 90c02f3a..8608699d 100644 --- a/assets/.asset_manifest_1.json +++ b/assets/.asset_manifest_1.json @@ -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", diff --git a/assets/Makefile b/assets/Makefile index 0348aeb6..f4eafcfa 100644 --- a/assets/Makefile +++ b/assets/Makefile @@ -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: $^ diff --git a/assets/src/ba_data/python/bacommon/servermanager.py b/assets/src/ba_data/python/bacommon/servermanager.py index 2b50d2f4..0c30a2a3 100644 --- a/assets/src/ba_data/python/bacommon/servermanager.py +++ b/assets/src/ba_data/python/bacommon/servermanager.py @@ -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 diff --git a/assets/src/ba_data/python/efro/dataclassutils.py b/assets/src/ba_data/python/efro/dataclassutils.py new file mode 100644 index 00000000..a709cea0 --- /dev/null +++ b/assets/src/ba_data/python/efro/dataclassutils.py @@ -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)) diff --git a/assets/src/ba_data/python/efro/jsonutils.py b/assets/src/ba_data/python/efro/jsonutils.py index 0ec2f492..db6f0106 100644 --- a/assets/src/ba_data/python/efro/jsonutils.py +++ b/assets/src/ba_data/python/efro/jsonutils.py @@ -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 diff --git a/assets/src/server/ballisticacore_server.py b/assets/src/server/ballisticacore_server.py index 51e8f129..974bac12 100755 --- a/assets/src/server/ballisticacore_server.py +++ b/assets/src/server/ballisticacore_server.py @@ -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 diff --git a/docs/ba_module.md b/docs/ba_module.md index 1d0c629f..3bdd59a3 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -1,5 +1,5 @@ -

last updated on 2020-04-29 for Ballistica version 1.5.0 build 20001

+

last updated on 2020-04-30 for Ballistica version 1.5.0 build 20001

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 let me know. Happy modding!


diff --git a/tests/test_efro/test_dataclassutils.py b/tests/test_efro/test_dataclassutils.py new file mode 100644 index 00000000..680789be --- /dev/null +++ b/tests/test_efro/test_dataclassutils.py @@ -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) diff --git a/tests/test_efro/test_entities.py b/tests/test_efro/test_entity.py similarity index 100% rename from tests/test_efro/test_entities.py rename to tests/test_efro/test_entity.py diff --git a/tools/batools/build.py b/tools/batools/build.py index 9a39ac7f..6bfae5df 100644 --- a/tools/batools/build.py +++ b/tools/batools/build.py @@ -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)