rewrite (core): Split and refactor Lion and config.

This commit is contained in:
2023-03-03 15:35:08 +02:00
parent aa326b759b
commit b0dcbaa727
18 changed files with 213 additions and 188 deletions

View File

@@ -150,9 +150,9 @@ class BabelCog(LionCog):
async def cog_load(self):
if not self.bot.core:
raise ValueError("CoreCog must be loaded first!")
self.bot.core.guild_settings.attach(LocaleSettings.ForceLocale)
self.bot.core.guild_settings.attach(LocaleSettings.GuildLocale)
self.bot.core.user_settings.attach(LocaleSettings.UserLocale)
self.bot.core.guild_config.register_model_setting(LocaleSettings.ForceLocale)
self.bot.core.guild_config.register_model_setting(LocaleSettings.GuildLocale)
self.bot.core.user_config.register_model_setting(LocaleSettings.UserLocale)
async def cog_unload(self):
pass
@@ -180,12 +180,12 @@ class BabelCog(LionCog):
"""
locale = None
if ctx.guild:
forced = ctx.alion.guild_settings['force_locale'].value
guild_locale = ctx.alion.guild_settings['guild_locale'].value
forced = ctx.lguild.config.get('force_locale').value
guild_locale = ctx.lguild.config.get('guild_locale').value
if forced:
locale = guild_locale
locale = locale or ctx.alion.user_settings['user_locale'].value
locale = locale or ctx.luser.config.get('user_locale').value
if ctx.interaction:
locale = locale or ctx.interaction.locale.value
if ctx.guild:

View File

@@ -11,8 +11,9 @@ from settings.groups import SettingGroup
from .data import CoreData
from .lion import Lions
from .guild_settings import GuildSettings
from .user_settings import UserSettings
from .lion_guild import GuildConfig
from .lion_member import MemberConfig
from .lion_user import UserConfig
class CoreCog(LionCog):
@@ -20,7 +21,7 @@ class CoreCog(LionCog):
self.bot = bot
self.data = CoreData()
bot.db.load_registry(self.data)
self.lions = Lions(bot)
self.lions = Lions(bot, self.data)
self.app_config: Optional[CoreData.AppConfig] = None
self.bot_config: Optional[CoreData.BotConfig] = None
@@ -35,19 +36,13 @@ class CoreCog(LionCog):
# Some ModelSetting registries
# These are for more convenient direct access
self.guild_settings = GuildSettings
self.user_settings = UserSettings
self.guild_config = GuildConfig
self.user_config = UserConfig
self.member_config = MemberConfig
self.app_cmd_cache: list[discord.app_commands.AppCommand] = []
self.cmd_name_cache: dict[str, discord.app_commands.AppCommand] = {}
async def bot_check_once(self, ctx: LionContext): # type: ignore
lion = await self.lions.fetch(ctx.guild.id if ctx.guild else 0, ctx.author.id)
if ctx.guild:
await lion.touch_discord_models(ctx.author) # type: ignore # Type checker doesn't recognise guard
ctx.alion = lion
return True
async def cog_load(self):
# Fetch (and possibly create) core data rows.
conn = await self.bot.db.get_connection()

View File

@@ -1,7 +0,0 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class GuildSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.Guild

View File

@@ -1,153 +1,96 @@
from typing import Optional
from cachetools import LRUCache
import datetime
import discord
from meta import LionCog, LionBot, LionContext
from settings import InteractiveSetting
from utils.lib import utc_now
from data import WeakCache
from .data import CoreData
from .user_settings import UserSettings
from .guild_settings import GuildSettings
class Lion:
"""
A Lion is a high level representation of a Member in the LionBot paradigm.
All members interacted with by the application should be available as Lions.
It primarily provides an interface to the User and Member data.
Lion also provides centralised access to various Member properties and methods,
that would normally be served by other cogs.
Many Lion methods may only be used when the required cogs and extensions are loaded.
A Lion may exist without a Bot instance or a Member in cache,
although the functionality available will be more limited.
There is no guarantee that a corresponding discord Member actually exists.
"""
__slots__ = ('bot', 'data', 'user_data', 'guild_data', '_member', '__weakref__')
def __init__(self, bot: LionBot, data: CoreData.Member, user_data: CoreData.User, guild_data: CoreData.Guild):
self.bot = bot
self.data = data
self.user_data = user_data
self.guild_data = guild_data
self._member: Optional[discord.Member] = None
# Data properties
@property
def key(self):
return (self.data.guildid, self.data.userid)
@property
def guildid(self):
return self.data.guildid
@property
def userid(self):
return self.data.userid
@classmethod
def get(cls, guildid, userid):
return cls._cache_.get((guildid, userid), None)
# ModelSettings interfaces
@property
def guild_settings(self):
return GuildSettings(self.guildid, self.guild_data, bot=self.bot)
@property
def user_settings(self):
return UserSettings(self.userid, self.user_data, bot=self.bot)
# Setting interfaces
# Each of these return an initialised member setting
@property
def timezone(self):
pass
@property
def locale(self):
pass
# Time utilities
@property
def now(self):
"""
Returns current time-zone aware time for the member.
"""
pass
# Discord data cache
async def touch_discord_models(self, member: discord.Member):
"""
Update the stored discord data from the given user or member object.
Intended to be used when we get member data from events that may not be available in cache.
"""
# Can we do these in one query?
if member.guild and (self.guild_data.name != member.guild.name):
await self.guild_data.update(name=member.guild.name)
avatar_key = member.avatar.key if member.avatar else None
await self.user_data.update(avatar_hash=avatar_key, name=member.name, last_seen=utc_now())
if member.display_name != self.data.display_name:
await self.data.update(display_name=member.display_name)
async def get_member(self) -> Optional[discord.Member]:
"""
Retrieve the member object for this Lion, if possible.
If the guild or member cannot be retrieved, returns None.
"""
guild = self.bot.get_guild(self.guildid)
if guild is not None:
member = guild.get_member(self.userid)
if member is None:
try:
member = await guild.fetch_member(self.userid)
except discord.HTTPException:
pass
return member
from .lion_guild import LionGuild
from .lion_user import LionUser
from .lion_member import LionMember
class Lions(LionCog):
def __init__(self, bot: LionBot):
def __init__(self, bot: LionBot, data: CoreData):
self.bot = bot
self.data = data
# Full Lions cache
# Don't expire Lions with strong references
self._cache_: WeakCache[tuple[int, int], 'Lion'] = WeakCache(LRUCache(5000))
# Caches
# Using WeakCache so strong references stay consistent
self.lion_guilds = WeakCache(LRUCache(2500))
self.lion_users = WeakCache(LRUCache(2000))
self.lion_members = WeakCache(LRUCache(5000))
self._settings_: dict[str, InteractiveSetting] = {}
async def fetch(self, guildid, userid) -> Lion:
async def bot_check_once(self, ctx: LionContext):
"""
Fetch or create the given Member.
If the guild or user row doesn't exist, also creates it.
Relies on the core cog existing, to retrieve the core data.
Insert the high-level Lion objects into context before command execution.
Creates the objects if they do not already exist.
Updates relevant saved data from the Discord models,
and updates last seen for the LionUser (for data lifetime).
"""
# TODO: Find a way to reduce this to one query, while preserving cache
lion = self._cache_.get((guildid, userid))
if lion is None:
if self.bot.core:
data = self.bot.core.data
else:
raise ValueError("Cannot fetch Lion before core module is attached.")
if ctx.guild:
# TODO: Consider doing all updates in one query, maybe with a View trigger on Member
lmember = ctx.lmember = await self.fetch_member(ctx.guild.id, ctx.author.id, ctx.author)
await lmember.touch_discord_model(ctx.author)
guild = await data.Guild.fetch_or_create(guildid)
user = await data.User.fetch_or_create(userid)
member = await data.Member.fetch_or_create(guildid, userid)
lion = Lion(self.bot, member, user, guild)
self._cache_[(guildid, userid)] = lion
return lion
ctx.luser = lmember.luser
await ctx.luser.touch_discord_model(ctx.author, seen=True)
def add_model_setting(self, setting: InteractiveSetting):
self._settings_[setting.__class__.__name__] = setting
return setting
ctx.lguild = lmember.lguild
await ctx.lguild.touch_discord_model(ctx.guild)
ctx.alion = lmember
else:
ctx.lmember = ctx.alion = None
ctx.lguild = None
luser = ctx.luser = await self.fetch_user(ctx.author.id, ctx.author)
await luser.touch_discord_model(ctx.author)
ctx.alion = luser
return True
async def fetch_user(self, userid, user: Optional[discord.User] = None) -> LionUser:
"""
Fetch the given LionUser, hitting cache if possible.
Creates the LionUser if it does not exist.
"""
if (luser := self.lion_users.get(userid, None)) is None:
data = await self.data.User.fetch_or_create(userid)
luser = LionUser(self.bot, data, user=user)
self.lion_users[userid] = luser
return luser
async def fetch_guild(self, guildid, guild: Optional[discord.Guild] = None) -> LionGuild:
"""
Fetch the given LionGuild, hitting cache if possible.
Creates the LionGuild if it does not exist.
"""
if (lguild := self.lion_guilds.get(guildid, None)) is None:
data = await self.data.Guild.fetch_or_create(guildid)
lguild = LionGuild(self.bot, data, guild=guild)
self.lion_guilds[guildid] = lguild
return lguild
async def fetch_member(self, guildid, userid, member: Optional[discord.Member] = None) -> LionMember:
"""
Fetch the given LionMember, using cache for data if possible.
Creates the LionGuild, LionUser, and LionMember if they do not already exist.
"""
# TODO: Can we do this more efficiently with one query, while keeping cache? Multiple joins?
key = (guildid, userid)
if (lmember := self.lion_members.get(key, None)) is None:
lguild = await self.fetch_guild(guildid, member.guild if member is not None else None)
luser = await self.fetch_user(userid, member)
data = await self.data.Member.fetch_or_create(guildid, userid)
lmember = LionMember(self.bot, data, lguild, luser, member)
self.lion_members[key] = lmember
return lmember

View File

@@ -1,7 +0,0 @@
from settings.groups import ModelSettings, SettingDotDict
from .data import CoreData
class UserSettings(ModelSettings):
_settings = SettingDotDict()
model = CoreData.User

Submodule src/gui updated: 4f3d5740a3...2beff63ddc

View File

@@ -8,7 +8,9 @@ from discord.ext.commands import Context
if TYPE_CHECKING:
from .LionBot import LionBot
from core.lion import Lion
from core.lion_member import LionMember
from core.lion_user import LionUser
from core.lion_guild import LionGuild
logger = logging.getLogger(__name__)
@@ -45,7 +47,10 @@ class LionContext(Context['LionBot']):
Extends Context to add Lion-specific methods and attributes.
Also adds several contextual wrapped utilities for simpler user during command invocation.
"""
alion: 'Lion'
luser: 'LionUser'
lguild: 'LionGuild'
lmember: 'LionMember'
alion: 'LionUser | LionMember'
def __repr__(self):
parts = {}

View File

@@ -415,7 +415,7 @@ class Economy(LionCog):
).on_conflict(ignore=True)
else:
# With only one target, we can take a simpler path, and make better use of local caches.
await self.bot.core.lions.fetch(ctx.guild.id, target.id)
await self.bot.core.lions.fetch_member(ctx.guild.id, target.id)
# Now we are certain these members have a database row
# Perform the appropriate action
@@ -889,7 +889,7 @@ class Economy(LionCog):
t = self.bot.translator.t
Member = self.bot.core.data.Member
target_lion = await self.bot.core.lions.fetch(ctx.guild.id, target.id)
target_lion = await self.bot.core.lions.fetch_member(ctx.guild.id, target.id)
# TODO: Add a "Send thanks" button to the DM?
# Alternative flow could be waiting until the target user presses accept

View File

@@ -11,7 +11,7 @@ from babel.translator import LazyStr
from ..data import ShopData
if TYPE_CHECKING:
from core.lion import Lion
from core.lion_member import LionMember
class ShopCog(LionCog):
@@ -65,7 +65,7 @@ class Customer:
self.bot = bot
self.data = shop_data
self.lion: 'Lion' = lion
self.lion: 'LionMember' = lion
# A list of InventoryItems held by this customer
self.inventory = inventory
@@ -84,7 +84,7 @@ class Customer:
@classmethod
async def fetch(cls, bot: LionBot, shop_data: ShopData, guildid: int, userid: int):
lion = await bot.core.lions.fetch(guildid, userid)
lion = await bot.core.lions.fetch_member(guildid, userid)
inventory = await shop_data.MemberInventoryInfo.fetch_inventory_info(guildid, userid)
return cls(bot, shop_data, lion, inventory)
@@ -92,7 +92,7 @@ class Customer:
"""
Refresh the data for this member.
"""
self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid)
self.lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
await self.lion.data.refresh()
self.inventory = await self.data.MemberInventoryInfo.fetch_inventory_info(self.guildid, self.userid)
return self

View File

@@ -184,7 +184,7 @@ class ColourShop(Shop):
)
# Ensure the customer member actually exists
member = await self.customer.lion.get_member()
member = await self.customer.lion.fetch_member()
if member is None:
raise SafeCancellation(
t(_p(

View File

@@ -15,7 +15,7 @@ async def get_goals_card(
):
data: StatsData = bot.get_cog('StatsCog').data
lion = await bot.core.lions.fetch(guildid, userid)
lion = await bot.core.lions.fetch_member(guildid, userid)
today = lion.today
# Calculate periodid and select the correct model
@@ -63,7 +63,7 @@ async def get_goals_card(
sessions_complete = 0.5
# Get member profile
if member := await lion.get_member():
if member := await lion.fetch_member():
username = (member.display_name, member.discriminator)
avatar = member.avatar.key
else:

View File

@@ -14,7 +14,7 @@ from ..lib import apply_month_offset
async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> MonthlyStatsCard:
data: StatsData = bot.get_cog('StatsCog').data
lion = await bot.core.lions.fetch(guildid, userid)
lion = await bot.core.lions.fetch_member(guildid, userid)
today = lion.today
month_start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
target = apply_month_offset(month_start, offset)
@@ -77,7 +77,7 @@ async def get_monthly_card(bot: LionBot, userid: int, guildid: int, offset: int,
monthly[i][day.day - 1] = stat / 3600
# Get member profile
if member := await lion.get_member():
if member := await lion.fetch_member():
username = (member.display_name, member.discriminator)
else:
username = (lion.data.display_name, '#????')

View File

@@ -16,7 +16,7 @@ async def get_stats_card(bot: LionBot, userid: int, guildid: int):
# TODO: Leaderboard rankings
guildid = guildid or 0
lion = await bot.core.lions.fetch(guildid, userid)
lion = await bot.core.lions.fetch_member(guildid, userid)
# Calculate the period timestamps, i.e. start time for each summary period
# TODO: Don't do the alltime one like this, not efficient anymore

View File

@@ -12,7 +12,7 @@ from ..data import StatsData
async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int, mode: CardMode) -> WeeklyStatsCard:
data: StatsData = bot.get_cog('StatsCog').data
lion = await bot.core.lions.fetch(guildid, userid)
lion = await bot.core.lions.fetch_member(guildid, userid)
today = lion.today
week_start = today - timedelta(days=today.weekday()) - timedelta(weeks=offset)
days = [week_start + timedelta(i) for i in range(-7, 7 if offset else (today.weekday() + 1))]
@@ -34,7 +34,7 @@ async def get_weekly_card(bot: LionBot, userid: int, guildid: int, offset: int,
day_stats.append(0)
# Get member profile
if member := await lion.get_member():
if member := await lion.fetch_member():
username = (member.display_name, member.discriminator)
else:
username = (lion.data.display_name, '#????')

View File

@@ -21,7 +21,7 @@ from babel.translator import ctx_translator, LazyStr
from babel.utils import local_month
from gui.cards import WeeklyGoalCard, WeeklyStatsCard, MonthlyGoalCard, MonthlyStatsCard
from gui.base import CardMode
from core.lion import Lion
from core.lion_member import LionMember
from ..graphics.weekly import get_weekly_card
from ..graphics.monthly import get_monthly_card
@@ -338,7 +338,7 @@ class WeeklyMonthlyUI(StatsUI):
self.data: StatsData = bot.get_cog('StatsCog').data
# State
self.lion: Optional[Lion] = None
self.lion: Optional[LionMember] = None
self._stat_page: StatPage = StatPage.WEEKLY_VOICE
self._week_offset = 0
@@ -859,7 +859,7 @@ class WeeklyMonthlyUI(StatsUI):
"""
self._original = interaction
self._showing_global = False
self.lion = await self.bot.core.lions.fetch(self.guildid, self.userid)
self.lion = await self.bot.core.lions.fetch_member(self.guildid, self.userid)
# TODO: Switch to using data cache in reload to calculate global/local

View File

@@ -130,8 +130,8 @@ class TasklistCog(LionCog):
async def cog_load(self):
await self.data.init()
self.bot.core.guild_settings.attach(self.settings.task_reward)
self.bot.core.guild_settings.attach(self.settings.task_reward_limit)
self.bot.core.guild_config.register_model_setting(self.settings.task_reward)
self.bot.core.guild_config.register_model_setting(self.settings.task_reward_limit)
# TODO: Better method for getting single load
# Or better, unloading crossloaded group

View File

@@ -88,6 +88,70 @@ class ModelSetting(ModelData, BaseSetting):
...
class ModelConfig:
"""
A ModelConfig provides a central point of configuration for any object described by a single Model.
An instance of a ModelConfig represents configuration for a single object
(given by a single row of the corresponding Model).
The ModelConfig also supports registration of non-model configuration,
to support associated settings (e.g. list-settings) for the object.
This is an ABC, and must be subclassed for each object-type.
"""
settings: SettingDotDict
_model_settings: set
model: Type[RowModel]
def __init__(self, parent_id, row, **kwargs):
self.parent_id = parent_id
self.row = row
self.kwargs = kwargs
@classmethod
def register_setting(cls, setting_cls):
"""
Decorator to register a non-model setting as part of the object configuration.
The setting class may be re-accessed through the `settings` class attr.
Subclasses may provide alternative access pathways to key non-model settings.
"""
cls.settings[setting_cls.setting_id] = setting_cls
return setting_cls
@classmethod
def register_model_setting(cls, model_setting_cls):
"""
Decorator to register a model setting as part of the object configuration.
The setting class may be accessed through the `settings` class attr.
A fresh setting instance may also be retrieved (using cached data)
through the `get` instance method.
Subclasses are recommended to provide model settings as properties
for simplified access and type checking.
"""
cls._model_settings.add(model_setting_cls.setting_id)
return cls.register_setting(model_setting_cls)
def get(self, setting_id):
"""
Retrieve a freshly initialised copy of the given model-setting.
The given `setting_id` must have been previously registered through `register_model_setting`.
This uses cached data, and so is not guaranteed to be up-to-date.
"""
if setting_id not in self._model_settings:
# TODO: Log
raise ValueError
setting_cls = self.settings[setting_id]
data = setting_cls._read_from_row(self.parent_id, self.row, **self.kwargs)
return setting_cls(self.parent_id, data, **self.kwargs)
class ModelSettings:
"""
A ModelSettings instance aggregates multiple `ModelSetting` instances

View File

@@ -1,6 +1,7 @@
from typing import NamedTuple, Optional, Sequence, Union, overload, List
import datetime
import iso8601 # type: ignore
import pytz
import re
from contextvars import Context
@@ -706,3 +707,34 @@ def parse_duration(string: str) -> Optional[int]:
seconds += int(match.group('value')) * multiplier
return seconds if found else None
class Timezoned:
"""
ABC mixin for objects with a set timezone.
Provides several useful localised properties.
"""
__slots__ = ()
@property
def timezone(self) -> pytz.timezone:
"""
Must be implemented by the deriving class!
"""
raise NotImplementedError
@property
def now(self):
"""
Return the current time localised to the object's timezone.
"""
return datetime.datetime.now(tz=self.timezone)
@property
def today(self):
"""
Return the start of the day localised to the object's timezone.
"""
now = self.now
return now.replace(hour=0, minute=0, second=0, microsecond=0)