Rename history to state as it's now storing more than just the history

This commit is contained in:
Marc Di Luzio 2024-08-11 12:16:23 +01:00
parent c93c5b9ecd
commit 78834f5319
5 changed files with 149 additions and 40 deletions

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
__pycache__ __pycache__
config.json config.json
history.json state.json
.venv .venv

View file

@ -3,7 +3,7 @@ import logging
import random import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Protocol, runtime_checkable 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 # 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], def attempt_create_groups(matchees: list[Member],
hist: history.History, hist: state.State,
oldest_relevant_ts: datetime, oldest_relevant_ts: datetime,
per_group: int) -> tuple[bool, list[list[Member]]]: per_group: int) -> tuple[bool, list[list[Member]]]:
"""History aware group matching""" """History aware group matching"""
@ -106,7 +106,7 @@ def attempt_create_groups(matchees: list[Member],
matchee_matches = hist.matchees.get( matchee_matches = hist.matchees.get(
str(matchee.id), {}).get("matches", {}) str(matchee.id), {}).get("matches", {})
relevant_matches = list(int(id) for id, ts in matchee_matches.items() 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 # Try every single group from the current group onwards
# Progressing through the groups like this ensures we slowly fill them up with compatible people # 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], def members_to_groups(matchees: list[Member],
hist: history.History = history.History(), hist: state.State = state.State(),
per_group: int = 3, per_group: int = 3,
allow_fallback: bool = False) -> list[list[Member]]: allow_fallback: bool = False) -> list[list[Member]]:
"""Generate the groups from the set of matchees""" """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 rand = random.Random(117) # Some stable randomness
# Grab the oldest timestamp # 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 # 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()): for oldest_relevant_datetime in datetime_range(history_start, _ATTEMPT_TIMESTEP_INCREMENT, datetime.now()):

View file

@ -5,7 +5,7 @@ import discord
import pytest import pytest
import random import random
import matching import matching
import history import state
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -44,16 +44,16 @@ class Member():
return self._id 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""" """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 # We should always have one group
assert len(groups) assert len(groups)
# Log the groups to history # Log the groups to history
# This will validate the internals # 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 # Ensure each group contains within the bounds of expected members
for group in groups: 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"]) ], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"])
def test_members_to_groups_no_history(matchees, per_group): def test_members_to_groups_no_history(matchees, per_group):
"""Test simple group matching works""" """Test simple group matching works"""
hist = history.History() tmp_state = state.State()
inner_validate_members_to_groups(matchees, hist, per_group) inner_validate_members_to_groups(matchees, tmp_state, per_group)
def items_found_in_lists(list_of_lists, items): 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", [ @pytest.mark.parametrize("history_data, matchees, per_group, checks", [
# Slightly more difficult test # 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), "ts": datetime.now() - timedelta(days=1),
@ -156,13 +156,13 @@ def items_found_in_lists(list_of_lists, items):
], ids=['simple_history', 'fallback']) ], ids=['simple_history', 'fallback'])
def test_members_to_groups_with_history(history_data, matchees, per_group, checks): def test_members_to_groups_with_history(history_data, matchees, per_group, checks):
"""Test more advanced group matching works""" """Test more advanced group matching works"""
hist = history.History() tmp_state = state.State()
# Replay the history # Replay the history
for d in history_data: 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 # Run the custom validate functions
for check in checks: for check in checks:
@ -208,8 +208,8 @@ def test_members_to_groups_stress_test():
rand.shuffle(history_data) rand.shuffle(history_data)
# Replay the history # Replay the history
hist = history.History() tmp_state = state.State()
for d in history_data: 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)

View file

@ -6,13 +6,13 @@ import discord
from discord import app_commands from discord import app_commands
from discord.ext import commands from discord.ext import commands
import matching import matching
import history import state
import config import config
import re import re
Config = config.load() Config = config.load()
History = history.load() State = state.load()
logger = logging.getLogger("matchy") logger = logging.getLogger("matchy")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -68,22 +68,6 @@ async def close(ctx: commands.Context):
await bot.close() 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") @bot.tree.command(description="Match up matchees")
@commands.guild_only() @commands.guild_only()
@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)", @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( matchees = list(
m for m in interaction.channel.members if matchee in m.roles) m for m in interaction.channel.members if matchee in m.roles)
groups = matching.members_to_groups( 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 # 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) 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( matchees = list(
m for m in interaction.channel.members if matchee in m.roles) m for m in interaction.channel.members if matchee in m.roles)
groups = matching.members_to_groups( groups = matching.members_to_groups(
matchees, History, self.min, allow_fallback=True) matchees, State, self.min, allow_fallback=True)
# Send the groups # Send the groups
for msg in (matching.group_to_message(g) for g in 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!") await interaction.channel.send("That's all folks, happy matching and remember - DFTBA!")
# Save the groups to the history # 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.", logger.info("Done. Matched %s matchees into %s groups.",
len(matchees), len(groups)) len(matchees), len(groups))

125
state.py Normal file
View file

@ -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)