From 78834f53196a6d8e6992df41a1a124098fadc5bc Mon Sep 17 00:00:00 2001 From: Marc Di Luzio Date: Sun, 11 Aug 2024 12:16:23 +0100 Subject: [PATCH] Rename history to state as it's now storing more than just the history --- .gitignore | 2 +- matching.py | 10 ++-- matching_test.py | 26 +++++----- matchy.py | 26 ++-------- state.py | 125 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 40 deletions(-) create mode 100644 state.py diff --git a/.gitignore b/.gitignore index 1c6e046..2296932 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ __pycache__ config.json -history.json +state.json .venv \ No newline at end of file diff --git a/matching.py b/matching.py index 49dc5c7..3b51a1d 100644 --- a/matching.py +++ b/matching.py @@ -3,7 +3,7 @@ import logging import random from datetime import datetime, timedelta from typing import Protocol, runtime_checkable -import history +import state # Number of days to step forward from the start of history for each match attempt @@ -88,7 +88,7 @@ def get_member_group_eligibility_score(member: Member, def attempt_create_groups(matchees: list[Member], - hist: history.History, + hist: state.State, oldest_relevant_ts: datetime, per_group: int) -> tuple[bool, list[list[Member]]]: """History aware group matching""" @@ -106,7 +106,7 @@ def attempt_create_groups(matchees: list[Member], matchee_matches = hist.matchees.get( str(matchee.id), {}).get("matches", {}) relevant_matches = list(int(id) for id, ts in matchee_matches.items() - if history.ts_to_datetime(ts) >= oldest_relevant_ts) + if state.ts_to_datetime(ts) >= oldest_relevant_ts) # Try every single group from the current group onwards # Progressing through the groups like this ensures we slowly fill them up with compatible people @@ -144,7 +144,7 @@ def datetime_range(start_time: datetime, increment: timedelta, end: datetime): def members_to_groups(matchees: list[Member], - hist: history.History = history.History(), + hist: state.State = state.State(), per_group: int = 3, allow_fallback: bool = False) -> list[list[Member]]: """Generate the groups from the set of matchees""" @@ -152,7 +152,7 @@ def members_to_groups(matchees: list[Member], rand = random.Random(117) # Some stable randomness # Grab the oldest timestamp - history_start = hist.oldest() or datetime.now() + history_start = hist.oldest_history() or datetime.now() # Walk from the start of time until now using the timestep increment for oldest_relevant_datetime in datetime_range(history_start, _ATTEMPT_TIMESTEP_INCREMENT, datetime.now()): diff --git a/matching_test.py b/matching_test.py index 4331fe9..8409202 100644 --- a/matching_test.py +++ b/matching_test.py @@ -5,7 +5,7 @@ import discord import pytest import random import matching -import history +import state from datetime import datetime, timedelta @@ -44,16 +44,16 @@ class Member(): return self._id -def inner_validate_members_to_groups(matchees: list[Member], hist: history.History, per_group: int): +def inner_validate_members_to_groups(matchees: list[Member], tmp_state: state.State, per_group: int): """Inner function to validate the main output of the groups function""" - groups = matching.members_to_groups(matchees, hist, per_group) + groups = matching.members_to_groups(matchees, tmp_state, per_group) # We should always have one group assert len(groups) # Log the groups to history # This will validate the internals - hist.log_groups_to_history(groups) + tmp_state.log_groups(groups) # Ensure each group contains within the bounds of expected members for group in groups: @@ -81,8 +81,8 @@ def inner_validate_members_to_groups(matchees: list[Member], hist: history.Histo ], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"]) def test_members_to_groups_no_history(matchees, per_group): """Test simple group matching works""" - hist = history.History() - inner_validate_members_to_groups(matchees, hist, per_group) + tmp_state = state.State() + inner_validate_members_to_groups(matchees, tmp_state, per_group) def items_found_in_lists(list_of_lists, items): @@ -95,8 +95,8 @@ def items_found_in_lists(list_of_lists, items): @pytest.mark.parametrize("history_data, matchees, per_group, checks", [ # Slightly more difficult test - # Describe a history where we previously matched up some people and ensure they don't get rematched ( + # Describe a history where we previously matched up some people and ensure they don't get rematched [ { "ts": datetime.now() - timedelta(days=1), @@ -156,13 +156,13 @@ def items_found_in_lists(list_of_lists, items): ], ids=['simple_history', 'fallback']) def test_members_to_groups_with_history(history_data, matchees, per_group, checks): """Test more advanced group matching works""" - hist = history.History() + tmp_state = state.State() # Replay the history for d in history_data: - hist.log_groups_to_history(d["groups"], d["ts"]) + tmp_state.log_groups(d["groups"], d["ts"]) - groups = inner_validate_members_to_groups(matchees, hist, per_group) + groups = inner_validate_members_to_groups(matchees, tmp_state, per_group) # Run the custom validate functions for check in checks: @@ -208,8 +208,8 @@ def test_members_to_groups_stress_test(): rand.shuffle(history_data) # Replay the history - hist = history.History() + tmp_state = state.State() for d in history_data: - hist.log_groups_to_history(d["groups"], d["ts"]) + tmp_state.log_groups(d["groups"], d["ts"]) - inner_validate_members_to_groups(matchees, hist, per_group) + inner_validate_members_to_groups(matchees, tmp_state, per_group) diff --git a/matchy.py b/matchy.py index 7c7c891..a76d78c 100755 --- a/matchy.py +++ b/matchy.py @@ -6,13 +6,13 @@ import discord from discord import app_commands from discord.ext import commands import matching -import history +import state import config import re Config = config.load() -History = history.load() +State = state.load() logger = logging.getLogger("matchy") logger.setLevel(logging.INFO) @@ -68,22 +68,6 @@ async def close(ctx: commands.Context): await bot.close() -# @bot.tree.command(description="Sign up as a matchee in this server") -# @commands.guild_only() -# async def join(interaction: discord.Interaction): -# # TODO: Sign up -# await interaction.response.send_message( -# f"Awesome, great to have you on board {interaction.user.mention}!", ephemeral=True) - - -# @bot.tree.command(description="Leave the matchee list in this server") -# @commands.guild_only() -# async def leave(interaction: discord.Interaction): -# # TODO: Remove the user -# await interaction.response.send_message( -# f"No worries, see you soon {interaction.user.mention}!", ephemeral=True) - - @bot.tree.command(description="Match up matchees") @commands.guild_only() @app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", @@ -114,7 +98,7 @@ async def match(interaction: discord.Interaction, members_min: int = None, match matchees = list( m for m in interaction.channel.members if matchee in m.roles) groups = matching.members_to_groups( - matchees, History, members_min, allow_fallback=True) + matchees, State, members_min, allow_fallback=True) # Post about all the groups with a button to send to the channel groups_list = '\n'.join(matching.group_to_message(g) for g in groups) @@ -173,7 +157,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], matchees = list( m for m in interaction.channel.members if matchee in m.roles) groups = matching.members_to_groups( - matchees, History, self.min, allow_fallback=True) + matchees, State, self.min, allow_fallback=True) # Send the groups for msg in (matching.group_to_message(g) for g in groups): @@ -183,7 +167,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button], await interaction.channel.send("That's all folks, happy matching and remember - DFTBA!") # Save the groups to the history - History.save_groups_to_history(groups) + State.save_groups(groups) logger.info("Done. Matched %s matchees into %s groups.", len(matchees), len(groups)) diff --git a/state.py b/state.py new file mode 100644 index 0000000..524943a --- /dev/null +++ b/state.py @@ -0,0 +1,125 @@ +"""Store bot state""" +import os +from datetime import datetime +from schema import Schema, And, Use, Optional +from typing import Protocol +import files +import copy + +_FILE = "state.json" + +# Warning: Changing any of the below needs proper thought to ensure backwards compatibility +_DEFAULT_DICT = { + "history": {}, + "matchees": {} +} +_TIME_FORMAT = "%a %b %d %H:%M:%S %Y" +_SCHEMA = Schema( + { + Optional("history"): { + Optional(str): { # a datetime + "groups": [ + { + "members": [ + # The ID of each matchee in the match + And(Use(int)) + ] + } + ] + } + }, + Optional("matchees"): { + Optional(str): { + Optional("matches"): { + # Matchee ID and Datetime pair + Optional(str): And(Use(str)) + } + } + } + } +) + + +class Member(Protocol): + @property + def id(self) -> int: + pass + + +def ts_to_datetime(ts: str) -> datetime: + """Convert a ts to datetime using the internal format""" + return datetime.strptime(ts, _TIME_FORMAT) + + +def validate(dict: dict): + """Initialise and validate the state""" + _SCHEMA.validate(dict) + + +class State(): + def __init__(self, data: dict = _DEFAULT_DICT): + """Initialise and validate the state""" + validate(data) + self.__dict__ = copy.deepcopy(data) + + @property + def history(self) -> list[dict]: + return self.__dict__["history"] + + @property + def matchees(self) -> dict[str, dict]: + return self.__dict__["matchees"] + + def save(self) -> None: + """Save out the state""" + files.save(_FILE, self.__dict__) + + def oldest_history(self) -> datetime: + """Grab the oldest timestamp in history""" + if not self.history: + return None + times = (ts_to_datetime(dt) for dt in self.history.keys()) + return sorted(times)[0] + + def log_groups(self, groups: list[list[Member]], ts: datetime = datetime.now()) -> None: + """Log the groups""" + tmp_state = State(self.__dict__) + ts = datetime.strftime(ts, _TIME_FORMAT) + + # Grab or create the hitory item for this set of groups + history_item = {} + tmp_state.history[ts] = history_item + history_item_groups = [] + history_item["groups"] = history_item_groups + + for group in groups: + + # Add the group data + history_item_groups.append({ + "members": list(m.id for m in group) + }) + + # Update the matchee data with the matches + for m in group: + matchee = tmp_state.matchees.get(str(m.id), {}) + matchee_matches = matchee.get("matches", {}) + + for o in (o for o in group if o.id != m.id): + matchee_matches[str(o.id)] = ts + + matchee["matches"] = matchee_matches + tmp_state.matchees[str(m.id)] = matchee + + # Validate before storing the result + validate(self.__dict__) + self.__dict__ = tmp_state.__dict__ + + def save_groups(self, groups: list[list[Member]]) -> None: + """Save out the groups to the state file""" + self.log_groups(groups) + self.save() + + +def load() -> State: + """Load the state""" + return State(files.load(_FILE) if os.path.isfile(_FILE) else _DEFAULT_DICT)