You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

811 lines
26 KiB

"""
The MIT License (MIT)
Copyright (c) 2021-present Dolfies
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.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Final, Iterator, List, Optional, Sequence, Tuple, Union
from .enums import HubType, try_enum
from .metadata import Metadata
from .utils import SequenceProxy, SnowflakeList, murmurhash32
if TYPE_CHECKING:
from .abc import Snowflake
from .guild import Guild
from .state import ConnectionState
from .types.experiment import (
Filters as FiltersPayload,
GuildExperiment as GuildExperimentPayload,
Override as OverridePayload,
Population as PopulationPayload,
Rollout as RolloutPayload,
UserExperiment as AssignmentPayload,
)
__all__ = (
'ExperimentRollout',
'ExperimentFilters',
'ExperimentPopulation',
'ExperimentOverride',
'HoldoutExperiment',
'GuildExperiment',
'UserExperiment',
)
class ExperimentRollout:
"""Represents a rollout for an experiment population.
.. container:: operations
.. describe:: x in y
Checks if a position is eligible for the rollout.
.. versionadded:: 2.1
Attributes
-----------
population: :class:`ExperimentPopulation`
The population this rollout belongs to.
bucket: :class:`int`
The bucket the rollout grants.
ranges: List[Tuple[:class:`int`, :class:`int`]]
The position ranges of the rollout.
"""
__slots__ = ('population', 'bucket', 'ranges')
def __init__(self, population: ExperimentPopulation, data: RolloutPayload):
bucket, ranges = data
self.population = population
self.bucket: int = bucket
self.ranges: List[Tuple[int, int]] = [(range['s'], range['e']) for range in ranges]
def __repr__(self) -> str:
return f'<ExperimentRollout bucket={self.bucket} ranges={self.ranges!r}>'
def __contains__(self, item: int, /) -> bool:
for start, end in self.ranges:
if start <= item <= end:
return True
return False
class ExperimentFilters:
"""Represents a number of filters for an experiment population.
A guild must fulfill all filters to be eligible for the population.
This is a purposefuly very low-level object.
.. container:: operations
.. describe:: x in y
Checks if a guild fulfills the filter requirements.
.. versionadded:: 2.1
Attributes
-----------
population: :class:`ExperimentPopulation`
The population this filter belongs to.
options: :class:`Metadata`
The parameters for the filter. If known, murmur3-hashed keys are unhashed to their original names.
.. note::
You should query parameters via the properties rather than using this directly.
"""
__slots__ = ('population', 'options')
# Most of these are taken from the client
FILTER_KEYS: Final[Dict[int, str]] = {
1604612045: 'guild_has_feature',
2404720969: 'guild_id_range',
2918402255: 'guild_member_count_range',
3013771838: 'guild_ids',
4148745523: 'guild_hub_types',
188952590: 'guild_has_vanity_url',
2294888943: 'guild_in_range_by_hash',
3399957344: 'min_id',
1238858341: 'max_id',
2690752156: 'hash_key',
1982804121: 'target',
1183251248: 'guild_features',
}
def __init__(self, population: ExperimentPopulation, data: FiltersPayload):
self.population = population
self.options: Metadata = self.array_object(data)
def __repr__(self) -> str:
keys = ('features', 'id_range', 'member_count_range', 'ids', 'range_by_hash', 'has_vanity_url')
attrs = [f'{attr}={getattr(self, attr)!r}' for attr in keys if getattr(self, attr) is not None]
if attrs:
return f'<ExperimentFilters {" ".join(attrs)}>'
return '<ExperimentFilters>'
def __contains__(self, guild: Guild, /) -> bool:
return self.is_eligible(guild)
@classmethod
def array_object(cls, array: list) -> Metadata:
metadata = Metadata()
for key, value in array:
try:
key = cls.FILTER_KEYS[int(key)]
except (KeyError, ValueError):
pass
if isinstance(value, str) and value.isdigit():
value = int(value)
elif value and isinstance(value, list) and isinstance(value[0], list):
value = cls.array_object(value)
metadata[str(key)] = value
return metadata
@staticmethod
def in_range(num: int, start: Optional[int], end: Optional[int], /) -> bool:
if start is not None and num < start:
return False
if end is not None and num > end:
return False
return True
@property
def features(self) -> Optional[List[str]]:
"""Optional[List[:class:`str`]]: The guild features that are eligible for the population."""
features_filter = self.options.guild_has_feature
if features_filter is not None:
return features_filter.guild_features
@property
def id_range(self) -> Optional[Tuple[Optional[int], Optional[int]]]:
"""Optional[Tuple[Optional[:class:`int`], Optional[:class:`int`]]]: The range of guild IDs that are eligible for the population."""
id_range_filter = self.options.guild_id_range
if id_range_filter is not None:
return id_range_filter.min_id, id_range_filter.max_id
@property
def member_count_range(self) -> Optional[Tuple[Optional[int], Optional[int]]]:
"""Optional[Tuple[Optional[:class:`int`], Optional[:class:`int`]]]: The range of guild member counts that are eligible for the population."""
member_count_range_filter = self.options.guild_member_count_range
if member_count_range_filter is not None:
return member_count_range_filter.min_id, member_count_range_filter.max_id
@property
def ids(self) -> Optional[List[int]]:
"""Optional[List[:class:`int`]]: The guild IDs that are eligible for the population."""
ids_filter = self.options.guild_ids
if ids_filter is not None:
return ids_filter.guild_ids
@property
def hub_types(self) -> Optional[List[HubType]]:
"""Optional[List[:class:`HubType`]]: The Student Hub types that are eligible for the population."""
hub_types_filter = self.options.guild_hub_types
if hub_types_filter is not None:
return [try_enum(HubType, hub_type) for hub_type in hub_types_filter.guild_hub_types]
@property
def range_by_hash(self) -> Optional[Tuple[int, int]]:
"""Optional[Tuple[:class:`int`, :class:`int`]]: The special rollout position limits on the population."""
range_by_hash_filter = self.options.guild_in_range_by_hash
if range_by_hash_filter is not None:
return range_by_hash_filter.hash_key, range_by_hash_filter.target
@property
def has_vanity_url(self) -> Optional[bool]:
"""Optional[:class:`bool`]: Whether a vanity is or is not required to be eligible for the population."""
has_vanity_url_filter = self.options.guild_has_vanity_url
if has_vanity_url_filter is not None:
return has_vanity_url_filter.target
def is_eligible(self, guild: Guild, /) -> bool:
"""Checks whether the guild fulfills the filter requirements.
.. note::
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`.
Parameters
-----------
guild: :class:`Guild`
The guild to check.
Returns
--------
:class:`bool`
Whether the guild fulfills the filter requirements.
"""
features = self.features
if features is not None:
# At least one feature must be present
if not any(feature in guild.features for feature in features):
return False
id_range = self.id_range
if id_range is not None:
# Guild must be within the range of snowflakes
if not self.in_range(guild.id, *id_range):
return False
member_count_range = self.member_count_range
if member_count_range is not None and guild.member_count is not None:
# Guild must be within the range of member counts
if not self.in_range(guild.member_count, *member_count_range):
return False
ids = self.ids
if ids is not None:
# Guild must be in the list of snowflakes, similar to ExperimentOverride
if guild.id not in ids:
return False
hub_types = self.hub_types
if hub_types is not None:
# Guild must be a hub in the list of hub types
if not guild.hub_type or guild.hub_type not in hub_types:
return False
range_by_hash = self.range_by_hash
if range_by_hash is not None:
# Guild must fulfill the additional population requirements
hash_key, target = range_by_hash
result = murmurhash32(f'{hash_key}:{guild.id}', signed=False)
if result > 0:
result += result
else:
result = (result % 0x100000000) >> 0
if target and (result % 10000) >= target:
return False
has_vanity_url = self.has_vanity_url
if has_vanity_url is not None:
# Guild must or must not have a vanity URL
if not bool(guild.vanity_url_code) == has_vanity_url:
return False
return True
class ExperimentPopulation:
"""Represents a population of an experiment.
.. container:: operations
.. describe:: x in y
Checks if a guild is present in the population.
.. versionadded:: 2.1
Attributes
-----------
experiment: :class:`GuildExperiment`
The experiment this population belongs to.
filters: :class:`ExperimentFilters`
The filters that apply to the population.
rollouts: List[Tuple[:class:`int`, :class:`int`]]
The position-based rollouts of the population.
"""
__slots__ = ('experiment', 'filters', 'rollouts')
def __init__(self, experiment: GuildExperiment, data: PopulationPayload):
rollouts, filters = data
self.experiment = experiment
self.filters: ExperimentFilters = ExperimentFilters(self, filters)
self.rollouts: List[ExperimentRollout] = [ExperimentRollout(self, x) for x in rollouts]
def __repr__(self) -> str:
return f'<ExperimentPopulation experiment={self.experiment!r} filters={self.filters!r} rollouts={self.rollouts!r}>'
def __contains__(self, item: Guild, /) -> bool:
return self.bucket_for(item) != -1
def bucket_for(self, guild: Guild, _result: Optional[int] = None, /) -> int:
"""Returns the assigned experiment bucket within a population for a guild.
Defaults to none (-1) if the guild is not in the population.
.. note::
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`.
Parameters
-----------
guild: :class:`Guild`
The guild to compute experiment eligibility for.
Raises
------
:exc:`ValueError`
The experiment name is unset.
Returns
-------
:class:`int`
The experiment bucket.
"""
if _result is None:
_result = self.experiment.result_for(guild)
if not self.filters.is_eligible(guild):
return -1
for rollout in self.rollouts:
for start, end in rollout.ranges:
if start <= _result <= end:
return rollout.bucket
return -1
class ExperimentOverride:
"""Represents an experiment override.
.. container:: operations
.. describe:: len(x)
Returns the number of resources eligible for the override.
.. describe:: x in y
Checks if a resource is eligible for the override.
.. describe:: iter(x)
Returns an iterator of the resources eligible for the override.
.. versionadded:: 2.1
Attributes
-----------
experiment: :class:`GuildExperiment`
The experiment this override belongs to.
bucket: :class:`int`
The bucket the override applies.
"""
__slots__ = ('experiment', 'bucket', '_ids')
def __init__(self, experiment: GuildExperiment, data: OverridePayload):
self.experiment = experiment
self.bucket: int = data['b']
self._ids: SnowflakeList = SnowflakeList(map(int, data['k']))
def __repr__(self) -> str:
return f'<ExperimentOverride bucket={self.bucket} ids={self.ids!r}>'
def __len__(self) -> int:
return len(self._ids)
def __contains__(self, item: Union[int, Snowflake], /) -> bool:
return getattr(item, 'id', item) in self._ids
def __iter__(self) -> Iterator[int]:
return iter(self._ids)
@property
def ids(self) -> Sequence[int]:
"""Sequence[:class:`int`]: The eligible guild/user IDs for the override."""
return SequenceProxy(self._ids)
class HoldoutExperiment:
"""Represents an experiment dependency.
.. container:: operations
.. describe:: x in y
Checks if a guild fulfills the dependency.
.. versionadded:: 2.1
Attributes
-----------
dependent: :class:`GuildExperiment`
The experiment that depends on this experiment.
name: :class:`str`
The name of the dependency.
bucket: :class:`int`
The required bucket of the dependency.
"""
__slots__ = ('dependent', 'name', 'bucket')
def __init__(self, dependent: GuildExperiment, name: str, bucket: int):
self.dependent = dependent
self.name: str = name
self.bucket: int = bucket
def __repr__(self) -> str:
return f'<HoldoutExperiment dependent={self.dependent!r} name={self.name!r} bucket={self.bucket}>'
def __contains__(self, item: Guild) -> bool:
return self.is_eligible(item)
@property
def experiment(self) -> Optional[GuildExperiment]:
"""Optional[:class:`GuildExperiment`]: The experiment dependency, if found."""
experiment_hash = murmurhash32(self.name, signed=False)
experiment = self.dependent._state.guild_experiments.get(experiment_hash)
if experiment and not experiment.name:
# Backfill the name
experiment._name = self.name
return experiment
def is_eligible(self, guild: Guild, /) -> bool:
"""Checks whether the guild fulfills the dependency.
.. note::
This function is not intended to be used directly. Instead, use :func:`GuildExperiment.bucket_for`.
Parameters
-----------
guild: :class:`Guild`
The guild to check.
Returns
--------
:class:`bool`
Whether the guild fulfills the dependency.
"""
experiment = self.experiment
if experiment is None:
# We don't have the experiment, so we can't check
return True
return experiment.bucket_for(guild) == self.bucket
class GuildExperiment:
"""Represents a guild experiment rollout.
.. container:: operations
.. describe:: x == y
Checks if two experiments are equal.
.. describe:: x != y
Checks if two experiments are not equal.
.. describe:: hash(x)
Returns the experiment's hash.
.. versionadded:: 2.1
Attributes
-----------
hash: :class:`int`
The 32-bit unsigned Murmur3 hash of the experiment's name.
revision: :class:`int`
The current revision of the experiment rollout.
populations: List[:class:`ExperimentPopulation`]
The rollout populations of the experiment.
overrides: List[:class:`ExperimentOverride`]
The explicit bucket overrides of the experiment.
overrides_formatted: List[List[:class:`ExperimentPopulation`]]
Additional rollout populations for the experiment.
holdout: Optional[:class:`HoldoutExperiment`]
The experiment this experiment depends on, if any.
aa_mode: :class:`bool`
Whether the experiment is in A/A mode.
trigger_debugging:
Whether experiment analytics trigger debugging is enabled.
"""
__slots__ = (
'_state',
'hash',
'_name',
'revision',
'populations',
'overrides',
'overrides_formatted',
'holdout',
'aa_mode',
'trigger_debugging',
)
def __init__(self, *, state: ConnectionState, data: GuildExperimentPayload):
(
hash,
hash_key,
revision,
populations,
overrides,
overrides_formatted,
holdout_name,
holdout_bucket,
aa_mode,
trigger_debugging,
*_,
) = data
self._state = state
self.hash: int = hash
self._name: Optional[str] = hash_key
self.revision: int = revision
self.populations: List[ExperimentPopulation] = [ExperimentPopulation(self, x) for x in populations]
self.overrides: List[ExperimentOverride] = [ExperimentOverride(self, x) for x in overrides]
self.overrides_formatted: List[List[ExperimentPopulation]] = [
[ExperimentPopulation(self, y) for y in x] for x in overrides_formatted
]
self.holdout: Optional[HoldoutExperiment] = (
HoldoutExperiment(self, holdout_name, holdout_bucket)
if holdout_name is not None and holdout_bucket is not None
else None
)
self.aa_mode: bool = aa_mode == 1
self.trigger_debugging: bool = trigger_debugging == 1
def __repr__(self) -> str:
return f'<GuildExperiment hash={self.hash}{f" name={self._name!r}" if self._name else ""}>'
def __hash__(self) -> int:
return self.hash
def __eq__(self, other: object, /) -> bool:
if isinstance(other, GuildExperiment):
return self.hash == other.hash
return NotImplemented
@property
def name(self) -> Optional[str]:
"""Optional[:class:`str`]: The unique name of the experiment.
This data is not always available via the API, and must be set manually for using related functions.
"""
return self._name
@name.setter
def name(self, value: Optional[str], /) -> None:
if not value:
self._name = None
elif murmurhash32(value, signed=False) != self.hash:
raise ValueError('The name provided does not match the experiment hash')
else:
self._name = value
def result_for(self, guild: Snowflake, /) -> int:
"""Returns the calulated position of the guild within the experiment (0-9999).
Parameters
-----------
guild: :class:`abc.Snowflake`
The guild to compute the position for.
Raises
------
:exc:`ValueError`
The experiment name is unset.
Returns
-------
:class:`int`
The position of the guild within the experiment.
"""
if not self.name:
raise ValueError('The experiment name must be set to compute the result')
return murmurhash32(f'{self.name}:{guild.id}', signed=False) % 10000
def bucket_for(self, guild: Guild, /) -> int:
"""Returns the assigned experiment bucket for a guild.
Defaults to none (-1) if the guild is not in the experiment.
Parameters
-----------
guild: :class:`Guild`
The guild to compute experiment eligibility for.
Raises
------
:exc:`ValueError`
The experiment name is unset.
Returns
-------
:class:`int`
The experiment bucket.
"""
# a/a mode is always -1
if self.aa_mode:
return -1
# Holdout must be fulfilled
if self.holdout and not self.holdout.is_eligible(guild):
return -1
hash_result = self.result_for(guild)
# Overrides take precedence
# And yes, they can be assigned to a user ID
for override in self.overrides:
if guild.id in override.ids or guild.owner_id in override.ids:
return override.bucket
for overrides in self.overrides_formatted:
for override in overrides:
pop_bucket = override.bucket_for(guild, hash_result)
if pop_bucket != -1:
return pop_bucket
for population in self.populations:
pop_bucket = population.bucket_for(guild, hash_result)
if pop_bucket != -1:
return pop_bucket
return -1
def guilds_for(self, bucket: int, /) -> List[Guild]:
"""Returns a list of guilds assigned to a specific bucket.
Parameters
-----------
bucket: :class:`int`
The bucket to get guilds for.
Raises
------
:exc:`ValueError`
The experiment name is unset.
Returns
-------
List[:class:`Guild`]
The guilds assigned to the bucket.
"""
return [x for x in self._state.guilds if self.bucket_for(x) == bucket]
class UserExperiment:
"""Represents a user's experiment assignment.
.. container:: operations
.. describe:: x == y
Checks if two experiments are equal.
.. describe:: x != y
Checks if two experiments are not equal.
.. describe:: hash(x)
Returns the experiment's hash.
.. note::
In contrast to the wide range of data provided for guild experiments,
user experiments do not reveal detailed rollout information, providing only the assigned bucket.
.. versionadded:: 2.1
Attributes
----------
hash: :class:`int`
The 32-bit unsigned Murmur3 hash of the experiment's name.
revision: :class:`int`
The current revision of the experiment rollout.
assignment: :class:`int`
The assigned bucket for the user.
override: :class:`int`
The overriden bucket for the user, takes precedence over :attr:`assignment`.
population: :class:`int`
The internal population group for the user.
aa_mode: :class:`bool`
Whether the experiment is in A/A mode.
trigger_debugging:
Whether experiment analytics trigger debugging is enabled.
"""
__slots__ = (
'_state',
'_name',
'hash',
'revision',
'assignment',
'override',
'population',
'_result',
'aa_mode',
'trigger_debugging',
)
def __init__(self, *, state: ConnectionState, data: AssignmentPayload):
(hash, revision, bucket, override, population, hash_result, aa_mode, trigger_debugging, *_) = data
self._state = state
self._name: Optional[str] = None
self.hash: int = hash
self.revision: int = revision
self.assignment: int = bucket
self.override: int = override
self.population: int = population
self._result: int = hash_result
self.aa_mode: bool = aa_mode == 1
self.trigger_debugging: bool = trigger_debugging == 1
def __repr__(self) -> str:
return f'<UserExperiment hash={self.hash}{f" name={self._name!r}" if self._name else ""} bucket={self.bucket}>'
def __hash__(self) -> int:
return self.hash
def __eq__(self, other: object, /) -> bool:
if isinstance(other, UserExperiment):
return self.hash == other.hash
return NotImplemented
@property
def name(self) -> Optional[str]:
"""Optional[:class:`str`]: The unique name of the experiment.
This data is not always available via the API, and must be set manually for using related functions.
"""
return self._name
@name.setter
def name(self, value: Optional[str], /) -> None:
if not value:
self._name = None
elif murmurhash32(value, signed=False) != self.hash:
raise ValueError('The name provided does not match the experiment hash')
else:
self._name = value
@property
def bucket(self) -> int:
""":class:`int`: The assigned bucket for the user."""
if self.aa_mode:
return -1
return self.override if self.override != -1 else self.population
@property
def result(self) -> int:
""":class:`int`: The calulated position of the user within the experiment (0-9999).
Raises
------
:exc:`ValueError`
The experiment name is unset without a precomputed result.
"""
if self._result:
return self._result
elif not self.name:
raise ValueError('The experiment name must be set to compute the result')
else:
return murmurhash32(f'{self.name}:{self._state.self_id}', signed=False) % 10000