Skip to content

Commit

Permalink
Use variconf for config loading
Browse files Browse the repository at this point in the history
Using variconf has several advantages:
- Easy merging of values from config file and command line arguments
- Automatic checks for missing values and wrong types
- Using a dataclass for the config instead of the dictionary-like
  ConfigProvider allows autocompletion of config values.

Remove the ConfigProvider class and instead add a module
`server.config`, which holds a `Config` dataclass and a global instance
which can be accessed via `get_config()`.  `load_config()` is used to
load config from the given config file and merges it with potential
overwrites from the command line.

BREAKING: The command line overwrites are now done like this:
```
... --config-overwrites port=1234 data_dir=/foo/bar ...
```
So there is no separate argument for each value anymore.  While this
reduces the ease of use a bit (no help text for possible arguments), it
greatly improves maintainability as config values only need to be
specified in one place.
  • Loading branch information
luator committed Jan 7, 2025
1 parent 21e13ce commit f7da4e9
Show file tree
Hide file tree
Showing 12 changed files with 132 additions and 161 deletions.
6 changes: 6 additions & 0 deletions comprl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`comprl.scripts.create_database`.
- BREAKING: New config option `data_dir` to specify output directory for game actions.
This is a required setting (no default value), hence a breaking change.
- BREAKING: The server main script (`python -m comprl.server`) does not provide
individual arguments to overwrite parameters anymore but instead uses a single
argument `--config-overwrites` which expects a list of parameters like this:
```
--config-overwrites port=1234 data_dir=/foo/bar ...
```

## Added
- Script `list_games` to list all games from the database on the terminal.
Expand Down
129 changes: 31 additions & 98 deletions comprl/comprl/server/__main__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
"""
class for server
"""
"""Run the comprl server."""

from __future__ import annotations

import os
import argparse
import logging as log
import importlib.abc
import importlib.util
import inspect
import logging as log
import os
import pathlib
from typing import Type, TYPE_CHECKING

try:
import tomllib # type: ignore[import-not-found]
except ImportError:
# tomllib was added in Python 3.11. Older versions can use tomli
import tomli as tomllib # type: ignore[import-not-found, no-redef]

import importlib.util
import importlib.abc

from comprl.server import networking
from comprl.server import config, networking
from comprl.server.managers import GameManager, PlayerManager, MatchmakingManager
from comprl.server.interfaces import IPlayer, IServer
from comprl.server.util import ConfigProvider

if TYPE_CHECKING:
from comprl.server.interfaces import IGame


class Server(IServer):
"""class for server"""

def __init__(self):
self.game_manager = GameManager(ConfigProvider.get("game_type"))
def __init__(self, game_type: Type[IGame]):
self.game_manager = GameManager(game_type)
self.player_manager = PlayerManager()
self.matchmaking = MatchmakingManager(self.player_manager, self.game_manager)

Expand Down Expand Up @@ -112,103 +108,40 @@ def main():
"""
Main function to start the server.
"""
parser = argparse.ArgumentParser(
description="The following arguments are supported:"
)
parser.add_argument("--config", type=str, help="Config file")
parser.add_argument("--port", type=int, help="Port to listen on")
parser.add_argument(
"--timeout", type=int, help="Seconds to wait for a player to answer"
)
parser.add_argument("--game_path", type=str, help="File containing the game to run")
parser.add_argument("--game_class", type=str, help="Class name of the game")
parser.add_argument("--log", type=str, help="Log level")
parser.add_argument("--database_path", type=str, help="Path to the database file.")
parser.add_argument("--data_dir", type=str, help="Path to the data directory.")
parser.add_argument(
"--match_quality_threshold",
type=float,
help="Threshold for matching players",
)
parser.add_argument(
"--percentage_min_players_waiting",
type=float,
help="Percentage of players always waiting in queue",
)
parser.add_argument(
"--percental_time_bonus",
type=float,
help="(Minutes waiting * percentage) added as a time bonus for waiting players",
)

parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=pathlib.Path, help="Config file")
parser.add_argument("--config-overwrites", type=str, nargs="+", default=[])
args = parser.parse_args()

data = None
if args.config is not None:
# load config file
with open(args.config, "rb") as f:
data = tomllib.load(f)["CompetitionServer"]
else:
print("No config file provided, using arguments or defaults")

if args.database_path:
database_path = args.database_path
elif data:
database_path = data["database_path"]
else:
parser.error("Need to provide either --config or --database-path")

# TODO better config management
port = args.port or (data["port"] if data else 65335)
timeout = args.timeout or (data["timeout"] if data else 10)
game_path = args.game_path or (data["game_path"] if data else "game.py")
game_class = args.game_class or (data["game_class"] if data else "Game")
log_level = args.log or (data["log"] if data else "INFO")
match_quality_threshold = args.match_quality_threshold or (
data["match_quality_threshold"] if data else 0.8
)
percentage_min_players_waiting = args.percentage_min_players_waiting or (
data["percentage_min_players_waiting"] if data else 0.1
)
percental_time_bonus = args.percental_time_bonus or (
data["percental_time_bonus"] if data else 0.1
)
data_dir = pathlib.Path(args.data_dir or data["data_dir"])
try:
conf = config.load_config(args.config, args.config_overwrites)
except Exception as e:
log.error("Failed to load config: %s", e)
return

# set up logging
log.basicConfig(level=log_level)
log.basicConfig(level=conf.log_level)

# get working directory
full_path = os.path.join(os.getcwd(), game_path)
# resolve relative game_path w.r.t. current working directory
absolute_game_path = os.path.join(os.getcwd(), conf.game_path)

# try to load the game class
game_type = load_class(full_path, game_class)
game_type = load_class(absolute_game_path, conf.game_class)
# check if the class could be loaded
if game_type is None:
log.error(f"Could not load game class from {full_path}")
log.error(f"Could not load game class from {absolute_game_path}")
return
# check if the class is fully implemented
if inspect.isabstract(game_type):
log.error("Provided game class is not valid because it is still abstract.")
return

if not data_dir.is_dir():
log.error("data_dir '%s' not found or not a directory", data_dir)
if not conf.data_dir.is_dir():
log.error("data_dir '%s' not found or not a directory", conf.data_dir)
return

# write the config to the ConfigProvider
ConfigProvider.set("port", port)
ConfigProvider.set("timeout", timeout)
ConfigProvider.set("log_level", log_level)
ConfigProvider.set("game_type", game_type)
ConfigProvider.set("database_path", database_path)
ConfigProvider.set("data_dir", data_dir)
ConfigProvider.set("match_quality_threshold", match_quality_threshold)
ConfigProvider.set("percentage_min_players_waiting", percentage_min_players_waiting)
ConfigProvider.set("percental_time_bonus", percental_time_bonus)

server = Server()
networking.launch_server(server, port)
server = Server(game_type)
networking.launch_server(server, conf.port)


if __name__ == "__main__":
Expand Down
69 changes: 69 additions & 0 deletions comprl/comprl/server/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Configuration settings for the server."""

import dataclasses
import pathlib

try:
import tomllib # type: ignore[import-not-found]
except ImportError:
# tomllib was added in Python 3.11. Older versions can use tomli
import tomli as tomllib # type: ignore[import-not-found, no-redef]

import omegaconf
import variconf


@dataclasses.dataclass
class Config:
"""Configuration settings."""

#: Port to listen on
port: int = 8080
#: Seconds to wait for a player to answer
timeout: int = 10
#: Log level used by the server
log_level: str = "INFO"
#: File containing the game class to run
game_path: pathlib.Path = omegaconf.MISSING
#: Class name of the game
game_class: str = omegaconf.MISSING
#: Path to the database file
database_path: pathlib.Path = omegaconf.MISSING
#: Path to the data directory (used to save data like game actions)
data_dir: pathlib.Path = omegaconf.MISSING
#: Threshold for matching players
match_quality_threshold: float = 0.8
#: Percentage of players always waiting in queue
percentage_min_players_waiting: float = 0.1
#: (Minutes waiting * percentage) added as a time bonus for waiting players
percental_time_bonus: float = 0.1


_config: Config | None = None


def get_config() -> Config:
"""Get global config instance."""
global _config
if _config is None:
_config = Config()
return _config


def set_config(config: Config):
"""Set global config instance."""
global _config
_config = config


def load_config(config_file: pathlib.Path, dotlist_overwrites: list[str]) -> Config:
"""Load config from config file and optional dotlist overwrites."""
wconf = variconf.WConf(Config)

with open(config_file, "rb") as f:
config_from_file = tomllib.load(f)["CompetitionServer"]

config = wconf.load_dict(config_from_file).load_dotlist(dotlist_overwrites)
set_config(Config(**config.get())) # type: ignore[arg-type]

return get_config()
5 changes: 3 additions & 2 deletions comprl/comprl/server/data/sql_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

import datetime
import os
from typing import Optional, Sequence

import bcrypt
Expand Down Expand Up @@ -65,7 +66,7 @@ class Game(Base):
class GameData:
"""Represents a data access object for managing game data in a SQLite database."""

def __init__(self, db_path: str) -> None:
def __init__(self, db_path: str | os.PathLike) -> None:
db_url = f"sqlite:///{db_path}"
self.engine = sa.create_engine(db_url)

Expand Down Expand Up @@ -112,7 +113,7 @@ def delete_all(self) -> None:
class UserData:
"""Represents a data access object for managing game data in a SQLite database."""

def __init__(self, db_path: str) -> None:
def __init__(self, db_path: str | os.PathLike) -> None:
"""
Initializes a new instance of the UserData class.
Expand Down
5 changes: 2 additions & 3 deletions comprl/comprl/server/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@
from datetime import datetime
import numpy as np
import pickle
import pathlib
import logging

from comprl.shared.types import GameID, PlayerID
from comprl.server.util import IDGenerator
from comprl.server.data.interfaces import GameResult, GameEndState
from comprl.server.util import ConfigProvider
from comprl.server.config import get_config


class IAction:
Expand Down Expand Up @@ -156,7 +155,7 @@ def _end(self, reason="unknown"):
# as storing the actions can take a while
self.game_info["actions"] = np.array(self.all_actions)

data_dir = pathlib.Path(ConfigProvider.get("data_dir"))
data_dir = get_config().data_dir
# should already be checked during config loading but just to be sure
assert data_dir.is_dir(), f"data_dir '{data_dir}' is not a directory"
game_actions_dir = data_dir / "game_actions"
Expand Down
22 changes: 10 additions & 12 deletions comprl/comprl/server/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from comprl.server.interfaces import IGame, IPlayer
from comprl.shared.types import GameID, PlayerID
from comprl.server.data import GameData, UserData
from comprl.server.util import ConfigProvider
from comprl.server.config import get_config


class GameManager:
Expand Down Expand Up @@ -58,7 +58,7 @@ def end_game(self, game: IGame) -> None:
if game.id in self.games:
game_result = game.get_result()
if game_result is not None:
GameData(ConfigProvider.get("database_path")).add(game_result)
GameData(get_config().database_path).add(game_result)
else:
log.error(f"Game had no valid result. Game-ID: {game.id}")
del self.games[game.id]
Expand Down Expand Up @@ -129,7 +129,7 @@ def auth(self, player_id: PlayerID, token: str) -> bool:
if player is None:
return False

id = UserData(ConfigProvider.get("database_path")).get_user_id(token)
id = UserData(get_config().database_path).get_user_id(token)

if id is not None:
# add player to authenticated players
Expand Down Expand Up @@ -224,9 +224,7 @@ def get_matchmaking_parameters(self, user_id: int) -> tuple[float, float]:
Returns:
tuple[float, float]: The mu and sigma values of the user.
"""
return UserData(ConfigProvider.get("database_path")).get_matchmaking_parameters(
user_id
)
return UserData(get_config().database_path).get_matchmaking_parameters(user_id)

def update_matchmaking_parameters(
self, user_id: int, new_mu: float, new_sigma: float
Expand All @@ -239,7 +237,7 @@ def update_matchmaking_parameters(
new_mu (float): The new mu value of the user.
new_sigma (float): The new sigma value of the user.
"""
UserData(ConfigProvider.get("database_path")).set_matchmaking_parameters(
UserData(get_config().database_path).set_matchmaking_parameters(
user_id, new_mu, new_sigma
)

Expand All @@ -265,15 +263,15 @@ def __init__(
self.player_manager = player_manager
self.game_manager = game_manager

config = get_config()

# queue storing player id, mu, sigma and time they joined the queue
self._queue: list[QueuePlayer] = []
# The model used for matchmaking
self.model = PlackettLuce()
self._match_quality_threshold = ConfigProvider.get("match_quality_threshold")
self._percentage_min_players_waiting = ConfigProvider.get(
"percentage_min_players_waiting"
)
self._percental_time_bonus = ConfigProvider.get("percental_time_bonus")
self._match_quality_threshold = config.match_quality_threshold
self._percentage_min_players_waiting = config.percentage_min_players_waiting
self._percental_time_bonus = config.percental_time_bonus

def try_match(self, player_id: PlayerID) -> None:
"""
Expand Down
Loading

0 comments on commit f7da4e9

Please sign in to comment.