Browse Source

Implement experiments (#520)

* start on experiments

Signed-off-by: Ruairi <[email protected]>

* Proper experiment types

* Small fixes

* add experiments to client

Signed-off-by: Ruairi <[email protected]>

* format using black

Signed-off-by: Ruairi <[email protected]>

* correct errors in filters

Signed-off-by: Ruairi <[email protected]>

* add suggested changes

Signed-off-by: Ruairi <[email protected]>

* oopsies

Signed-off-by: Ruairi <[email protected]>

* fix some errors hopefully

Signed-off-by: Ruairi <[email protected]>

* fix even more type errors

Signed-off-by: Ruairi <[email protected]>

* moved to utils

Signed-off-by: Ruairi <[email protected]>

* Fix overloads

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* add suggested changes

Signed-off-by: Ruairi <[email protected]>

* fixinator

Signed-off-by: Ruairi <[email protected]>

* properly cast

Signed-off-by: Ruairi <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/client.py

Co-authored-by: dolfies <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/http.py

Co-authored-by: dolfies <[email protected]>

* Update discord/experiment.py

Co-authored-by: dolfies <[email protected]>

* Update discord/utils.py

Co-authored-by: dolfies <[email protected]>

* fix type error

Signed-off-by: Ruairi <[email protected]>

* rename murmurhash32

Signed-off-by: Ruairi <[email protected]>

* fix!: apply suggestions

Signed-off-by: Ruairi <[email protected]>

* fix quotes

Signed-off-by: Ruairi <[email protected]>

* big fixesd

* logic fixes and simplification

* add mmh3 to optional reqs

* reqs my beloved

* documentation

* Proper filters

* Export experiment classes

* Fix Metadata object parsing

* More low-level method warnings

* Fix and improve performance of ExperimentOverride

* more positional-only markers

* fix small isues

Signed-off-by: Ruairi <[email protected]>

* fix formatting

Signed-off-by: Ruairi <[email protected]>

* put mmh3 shit without type checker and shit idk

* Remove str(exp)

---------

Signed-off-by: Ruairi <[email protected]>
Co-authored-by: dolfies <[email protected]>
pull/10109/head
Ruairi 2 years ago
committed by GitHub
parent
commit
67b69e732c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      discord/__init__.py
  2. 100
      discord/client.py
  3. 10
      discord/enums.py
  4. 730
      discord/experiment.py
  5. 2
      discord/gateway.py
  6. 25
      discord/http.py
  7. 33
      discord/metadata.py
  8. 8
      discord/state.py
  9. 102
      discord/types/experiment.py
  10. 35
      discord/types/gateway.py
  11. 65
      discord/utils.py
  12. 72
      docs/api.rst
  13. 1
      setup.py

1
discord/__init__.py

@ -39,6 +39,7 @@ from .emoji import *
from .entitlements import * from .entitlements import *
from .enums import * from .enums import *
from .errors import * from .errors import *
from .experiment import *
from .file import * from .file import *
from .flags import * from .flags import *
from .guild import * from .guild import *

100
discord/client.py

@ -36,6 +36,7 @@ from typing import (
Dict, Dict,
Generator, Generator,
List, List,
Literal,
Optional, Optional,
overload, overload,
Sequence, Sequence,
@ -90,6 +91,7 @@ from .relationship import FriendSuggestion, Relationship
from .settings import UserSettings, LegacyUserSettings, TrackingSettings, EmailSettings from .settings import UserSettings, LegacyUserSettings, TrackingSettings, EmailSettings
from .affinity import * from .affinity import *
from .oauth2 import OAuth2Authorization, OAuth2Token from .oauth2 import OAuth2Authorization, OAuth2Token
from .experiment import UserExperiment, GuildExperiment
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
@ -527,6 +529,48 @@ class Client:
""" """
return self._connection.tutorial return self._connection.tutorial
@property
def experiments(self) -> Sequence[UserExperiment]:
"""Sequence[:class:`.UserExperiment`]: The experiments assignments for the connected client.
.. versionadded:: 2.1
"""
return utils.SequenceProxy(self._connection.experiments.values())
@property
def guild_experiments(self) -> Sequence[GuildExperiment]:
"""Sequence[:class:`.GuildExperiment`]: The guild experiments assignments for the connected client.
.. versionadded:: 2.1
"""
return utils.SequenceProxy(self._connection.guild_experiments.values())
def get_experiment(self, experiment: Union[str, int], /) -> Optional[Union[UserExperiment, GuildExperiment]]:
"""Returns a user or guild experiment from the given experiment identifier.
Parameters
-----------
experiment: Union[:class:`str`, :class:`int`]
The experiment name or hash to search for.
Returns
--------
Optional[Union[:class:`.UserExperiment`, :class:`.GuildExperiment`]]
The experiment, if found.
"""
name = None
if not isinstance(experiment, int) and not experiment.isdigit():
name = experiment
experiment_hash = utils.murmurhash32(experiment, signed=False)
else:
experiment_hash = int(experiment)
exp = self._connection.experiments.get(experiment_hash, self._connection.guild_experiments.get(experiment_hash))
if exp and not exp.name and name:
# Backfill the name
exp.name = name
return exp
def is_ready(self) -> bool: def is_ready(self) -> bool:
""":class:`bool`: Specifies if the client's internal cache is ready for use.""" """:class:`bool`: Specifies if the client's internal cache is ready for use."""
return self._ready is not MISSING and self._ready.is_set() return self._ready is not MISSING and self._ready.is_set()
@ -4951,3 +4995,59 @@ class Client:
icon_data = utils._bytes_to_base64_data(icon.fp.read()) icon_data = utils._bytes_to_base64_data(icon.fp.read())
await state.http.upload_unverified_application_icon(app.name, app.hash, icon_data) await state.http.upload_unverified_application_icon(app.name, app.hash, icon_data)
return app return app
@overload
async def fetch_experiments(
self, with_guild_experiments: Literal[True] = ...
) -> List[Union[UserExperiment, GuildExperiment]]:
...
@overload
async def fetch_experiments(self, with_guild_experiments: Literal[False] = ...) -> List[UserExperiment]:
...
@overload
async def fetch_experiments(
self, with_guild_experiments: bool = True
) -> Union[List[UserExperiment], List[Union[UserExperiment, GuildExperiment]]]:
...
async def fetch_experiments(
self, with_guild_experiments: bool = True
) -> Union[List[UserExperiment], List[Union[UserExperiment, GuildExperiment]]]:
"""|coro|
Retrieves the experiment rollouts available in relation to the user.
.. versionadded:: 2.1
.. note::
Certain guild experiments are only available via the gateway.
See :attr:`guild_experiments` for these.
Parameters
-----------
with_guild_experiments: :class:`bool`
Whether to include guild experiment rollouts in the response.
Raises
-------
HTTPException
Retrieving the experiment assignments failed.
Returns
-------
List[Union[:class:`.UserExperiment`, :class:`.GuildExperiment`]]
The experiment rollouts.
"""
state = self._connection
data = await state.http.get_experiments(with_guild_experiments=with_guild_experiments)
experiments: List[Union[UserExperiment, GuildExperiment]] = [
UserExperiment(state=state, data=exp) for exp in data['assignments']
]
for exp in data.get('guild_experiments', []):
experiments.append(GuildExperiment(state=state, data=exp))
return experiments

10
discord/enums.py

@ -1538,6 +1538,16 @@ class ReadStateType(Enum):
onboarding = 4 onboarding = 4
class ExperimentFilterType(Enum):
feature = 1604612045
id_range = 2404720969
member_count_range = 2918402255
ids = 3013771838
hub_type = 4148745523
vanity_url = 188952590
hash_range = 2294888943
def create_unknown_value(cls: Type[E], val: Any) -> E: def create_unknown_value(cls: Type[E], val: Any) -> E:
value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below
name = f'unknown_{val}' name = f'unknown_{val}'

730
discord/experiment.py

@ -0,0 +1,730 @@
"""
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, Iterator, List, Optional, Sequence, Tuple, Union
from .enums import ExperimentFilterType, 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',
'ExperimentFilter',
'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 ExperimentFilter:
"""Represents a filter for an experiment 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.
type: :class:`ExperimentFilterType`
The type of filter.
options: :class:`Metadata`
The parameters for the filter.
If known, murmur3-hashed keys are unhashed to their original names.
"""
__slots__ = ('population', 'type', 'options')
# Most of these are taken from the client
FILTER_KEYS = {
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):
type, options = data
self.population = population
self.type: ExperimentFilterType = try_enum(ExperimentFilterType, type)
self.options = metadata = Metadata()
for key, value in options:
try:
key = self.FILTER_KEYS[int(key)]
except (KeyError, ValueError):
pass
if isinstance(value, str) and value.isdigit():
value = int(value)
metadata[str(key)] = value
def __repr__(self) -> str:
return f'<ExperimentFilter type={self.type!r} options={self.options!r}>'
def __contains__(self, guild: Guild, /) -> bool:
return self.is_eligible(guild)
@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
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.
"""
type = self.type
options = self.options
if type == ExperimentFilterType.feature:
# One feature must be present
return options.guild_features and any(feature in guild.features for feature in options.guild_features)
elif type == ExperimentFilterType.id_range:
# Guild must be within the range of snowflakes
return self.in_range(guild.id, options.min_id, options.max_id)
elif type == ExperimentFilterType.member_count_range:
# Guild must be within the range of member counts
return guild.member_count is not None and self.in_range(guild.member_count, options.min_id, options.max_id)
elif type == ExperimentFilterType.ids:
# Guild must be in the list of snowflakes, similar to ExperimentOverride
return options.guild_ids is not None and guild.id in options.guild_ids
elif type == ExperimentFilterType.hub_type:
# TODO: Pending hub implementation
# return guild.hub_type and options.guild_hub_types and guild.hub_type.value in options.guild_hub_types
return False
elif type == ExperimentFilterType.hash_range:
# Guild must... no idea tbh
# Probably for cleanly splitting populations
result = murmurhash32(f'{options.hash_key}:{guild.id}', signed=False)
if result > 0:
result += result
else:
result = (result % 0x100000000) >> 0
return options.target and result % 10000 < options.target
elif type == ExperimentFilterType.vanity_url:
# Guild must or must not have a vanity URL
return bool(guild.vanity_url_code) == options.guild_has_vanity_url
else:
# TODO: Maybe just return False?
raise NotImplementedError(f'Unknown filter type: {type}')
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: List[:class:`ExperimentFilter`]
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: List[ExperimentFilter] = [ExperimentFilter(self, x) for x in 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)
for filter in self.filters:
if not filter.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.
"""
__slots__ = (
'_state',
'hash',
'_name',
'revision',
'populations',
'overrides',
'overrides_formatted',
'holdout',
'aa_mode',
)
def __init__(self, *, state: ConnectionState, data: GuildExperimentPayload):
(
hash,
hash_key,
revision,
populations,
overrides,
overrides_formatted,
holdout_name,
holdout_bucket,
aa_mode,
) = 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
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.
.. versionadded:: 2.1
.. 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.
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.
"""
__slots__ = (
'_state',
'_name',
'hash',
'revision',
'assignment',
'override',
'population',
'_result',
'aa_mode',
)
def __init__(self, *, state: ConnectionState, data: AssignmentPayload):
(hash, revision, bucket, override, population, hash_result, aa_mode) = 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 = True if aa_mode == 1 else False
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

2
discord/gateway.py

@ -571,6 +571,7 @@ class DiscordWebSocket:
self.sequence = None self.sequence = None
self.session_id = None self.session_id = None
self.gateway = self.DEFAULT_GATEWAY self.gateway = self.DEFAULT_GATEWAY
_log.info('Gateway session has been invalidated.') _log.info('Gateway session has been invalidated.')
await self.close(code=1000) await self.close(code=1000)
raise ReconnectWebSocket(resume=False) raise ReconnectWebSocket(resume=False)
@ -583,6 +584,7 @@ class DiscordWebSocket:
self.sequence = msg['s'] self.sequence = msg['s']
self.session_id = data['session_id'] self.session_id = data['session_id']
self.gateway = yarl.URL(data['resume_gateway_url']) self.gateway = yarl.URL(data['resume_gateway_url'])
_log.info('Connected to Gateway (Session ID: %s).', self.session_id) _log.info('Connected to Gateway (Session ID: %s).', self.session_id)
await self.voice_state() # Initial OP 4 await self.voice_state() # Initial OP 4

25
discord/http.py

@ -91,6 +91,7 @@ if TYPE_CHECKING:
channel, channel,
emoji, emoji,
entitlements, entitlements,
experiment,
guild, guild,
integration, integration,
invite, invite,
@ -4655,3 +4656,27 @@ class HTTPClient:
raise Forbidden(resp, 'cannot retrieve rtc regions') raise Forbidden(resp, 'cannot retrieve rtc regions')
else: else:
raise HTTPException(resp, 'failed to get rtc regions') raise HTTPException(resp, 'failed to get rtc regions')
# Experiments
@overload
def get_experiments(
self, with_guild_experiments: Literal[True] = ...
) -> Response[experiment.ExperimentResponseWithGuild]:
...
@overload
def get_experiments(self, with_guild_experiments: Literal[False] = ...) -> Response[experiment.ExperimentResponse]:
...
@overload
def get_experiments(
self, with_guild_experiments: bool = True
) -> Response[Union[experiment.ExperimentResponse, experiment.ExperimentResponseWithGuild]]:
...
def get_experiments(
self, with_guild_experiments: bool = True
) -> Response[Union[experiment.ExperimentResponse, experiment.ExperimentResponseWithGuild]]:
params = {'with_guild_experiments': str(with_guild_experiments).lower()}
return self.request(Route('GET', '/experiments'), params=params, context_properties=ContextProperties.empty())

33
discord/metadata.py

@ -70,19 +70,29 @@ class Metadata:
return return
for key, value in data.items(): for key, value in data.items():
if isinstance(value, dict): key, value = self.__parse(key, value)
value = Metadata(value) self.__dict__[key] = value
elif key.endswith('_id') and isinstance(value, str) and value.isdigit():
value = int(value) @staticmethod
elif (key.endswith('_at') or key.endswith('_date')) and isinstance(value, str): def __parse(key: str, value: Any) -> Tuple[str, Any]:
if isinstance(value, dict):
value = Metadata(value)
elif isinstance(value, list):
if key.endswith('_ids'):
try: try:
value = parse_time(value) value = [int(x) for x in value]
except ValueError: except ValueError:
pass pass
elif isinstance(value, list): value = [Metadata(x) if isinstance(x, dict) else x for x in value]
value = [Metadata(x) if isinstance(x, dict) else x for x in value] elif key.endswith('_id') and isinstance(value, str) and value.isdigit():
value = int(value)
elif (key.endswith('_at') or key.endswith('_date')) and isinstance(value, str):
try:
value = parse_time(value)
except ValueError:
pass
self.__dict__[key] = value return key, value
def __repr__(self) -> str: def __repr__(self) -> str:
if not self.__dict__: if not self.__dict__:
@ -106,11 +116,16 @@ class Metadata:
return self.__dict__[key] return self.__dict__[key]
def __setitem__(self, key: str, value: Any) -> None: def __setitem__(self, key: str, value: Any) -> None:
key, value = self.__parse(key, value)
self.__dict__[key] = value self.__dict__[key] = value
def __getattr__(self, _) -> Any: def __getattr__(self, _) -> Any:
return None return None
def __setattr__(self, key: str, value: Any) -> None:
key, value = self.__parse(key, value)
self.__dict__[key] = value
def __contains__(self, key: str) -> bool: def __contains__(self, key: str) -> bool:
return key in self.__dict__ return key in self.__dict__

8
discord/state.py

@ -98,6 +98,7 @@ from .automod import AutoModRule, AutoModAction
from .audit_logs import AuditLogEntry from .audit_logs import AuditLogEntry
from .read_state import ReadState from .read_state import ReadState
from .tutorial import Tutorial from .tutorial import Tutorial
from .experiment import UserExperiment, GuildExperiment
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
@ -641,6 +642,9 @@ class ConnectionState:
else: else:
self._messages: Optional[Deque[Message]] = None self._messages: Optional[Deque[Message]] = None
self.experiments: Dict[int, UserExperiment] = {}
self.guild_experiments: Dict[int, GuildExperiment] = {}
def process_chunk_requests(self, guild_id: int, nonce: Optional[str], members: List[Member], complete: bool) -> None: def process_chunk_requests(self, guild_id: int, nonce: Optional[str], members: List[Member], complete: bool) -> None:
removed = [] removed = []
for key, request in self._chunk_requests.items(): for key, request in self._chunk_requests.items():
@ -1062,6 +1066,10 @@ class ConnectionState:
self.required_action = try_enum(RequiredActionType, data['required_action']) if 'required_action' in data else None self.required_action = try_enum(RequiredActionType, data['required_action']) if 'required_action' in data else None
self.friend_suggestion_count = data.get('friend_suggestion_count', 0) self.friend_suggestion_count = data.get('friend_suggestion_count', 0)
# Experiments
self.experiments = {exp[0]: UserExperiment(state=self, data=exp) for exp in data.get('experiments', [])}
self.guild_experiments = {exp[0]: GuildExperiment(state=self, data=exp) for exp in data.get('guild_experiments', [])}
if 'sessions' in data: if 'sessions' in data:
self.parse_sessions_replace(data['sessions'], from_ready=True) self.parse_sessions_replace(data['sessions'], from_ready=True)

102
discord/types/experiment.py

@ -0,0 +1,102 @@
"""
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 List, Literal, Optional, Tuple, TypedDict, Union
from typing_extensions import NotRequired
class ExperimentResponse(TypedDict):
fingerprint: NotRequired[str]
assignments: List[UserExperiment]
class ExperimentResponseWithGuild(ExperimentResponse):
guild_experiments: NotRequired[List[GuildExperiment]]
class RolloutData(TypedDict):
s: int
e: int
Rollout = Tuple[int, List[RolloutData]]
Filters = Union[
Tuple[Literal[1604612045], Tuple[Tuple[Literal[1183251248], List[str]]]], # FEATURE
Tuple[
Literal[2404720969], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]]
], # ID_RANGE
Tuple[
Literal[2918402255], Tuple[Tuple[Literal[3399957344], Optional[int]], Tuple[Literal[1238858341], int]]
], # MEMBER_COUNT_RANGE
Tuple[Literal[3013771838], Tuple[Tuple[Literal[3013771838], List[int]]]], # IDs
Tuple[Literal[4148745523], Tuple[Tuple[Literal[4148745523], List[int]]]], # HUB_TYPE
Tuple[Literal[188952590], Tuple[Tuple[Literal[188952590], bool]]], # VANITY_URL
Tuple[Literal[2294888943], Tuple[Tuple[Literal[2690752156], int], Tuple[Literal[1982804121], int]]], # RANGE_BY_HASH
]
Population = Tuple[
List[Rollout], # rollouts
List[Filters], # filters
]
class Override(TypedDict):
b: int
k: List[int]
Holdout = Tuple[
int, # bucket
str, # experiment_name
]
UserExperiment = Tuple[
int, # hash
int, # revision
int, # bucket
int, # override
int, # population
int, # hash_result
Literal[0, 1], # aa_mode
]
GuildExperiment = Tuple[
int, # hash
Optional[str], # hash_key
int, # revision
List[Population], # populations
List[Override], # overrides
List[List[Population]], # overrides_formatted
Optional[str], # holdout_name
Optional[int], # holdout_bucket
Literal[0, 1], # aa_mode
]

35
discord/types/gateway.py

@ -25,33 +25,34 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
from typing import List, Literal, Optional, TypedDict, Union from typing import List, Literal, Optional, TypedDict, Union
from typing_extensions import NotRequired, Required from typing_extensions import NotRequired, Required
from .activity import Activity, ClientStatus, PartialPresenceUpdate, StatusType from .activity import Activity, ClientStatus, PartialPresenceUpdate, StatusType
from .application import BaseAchievement, PartialApplication
from .audit_log import AuditLogEntry
from .automod import AutoModerationAction, AutoModerationRuleTriggerType from .automod import AutoModerationAction, AutoModerationRuleTriggerType
from .voice import GuildVoiceState from .channel import ChannelType, DMChannel, GroupDMChannel, StageInstance
from .emoji import Emoji, PartialEmoji
from .entitlements import Entitlement, GatewayGift
from .experiment import GuildExperiment, UserExperiment
from .guild import ApplicationCommandCounts, Guild, SupplementalGuild, UnavailableGuild
from .integration import BaseIntegration, IntegrationApplication from .integration import BaseIntegration, IntegrationApplication
from .role import Role
from .channel import ChannelType, StageInstance
from .interactions import Interaction from .interactions import Interaction
from .invite import InviteTargetType from .invite import InviteTargetType
from .emoji import Emoji, PartialEmoji from .library import LibraryApplication
from .member import MemberWithUser from .member import MemberWithUser
from .snowflake import Snowflake
from .message import Message from .message import Message
from .sticker import GuildSticker
from .application import BaseAchievement, PartialApplication
from .guild import ApplicationCommandCounts, Guild, UnavailableGuild, SupplementalGuild
from .user import Connection, FriendSuggestion, User, PartialUser, ProtoSettingsType, Relationship, RelationshipType
from .threads import Thread, ThreadMember
from .scheduled_event import GuildScheduledEvent
from .channel import DMChannel, GroupDMChannel
from .subscriptions import PremiumGuildSubscriptionSlot
from .payments import Payment from .payments import Payment
from .entitlements import Entitlement, GatewayGift
from .library import LibraryApplication
from .audit_log import AuditLogEntry
from .read_state import ReadState, ReadStateType from .read_state import ReadState, ReadStateType
from .role import Role
from .scheduled_event import GuildScheduledEvent
from .snowflake import Snowflake
from .sticker import GuildSticker
from .subscriptions import PremiumGuildSubscriptionSlot
from .threads import Thread, ThreadMember
from .user import Connection, FriendSuggestion, PartialUser, ProtoSettingsType, Relationship, RelationshipType, User
from .voice import GuildVoiceState
class UserPresenceUpdateEvent(TypedDict): class UserPresenceUpdateEvent(TypedDict):
@ -86,8 +87,10 @@ class ReadyEvent(ResumedEvent):
auth_token: NotRequired[str] auth_token: NotRequired[str]
connected_accounts: List[Connection] connected_accounts: List[Connection]
country_code: str country_code: str
experiments: List[UserExperiment]
friend_suggestion_count: int friend_suggestion_count: int
geo_ordered_rtc_regions: List[str] geo_ordered_rtc_regions: List[str]
guild_experiments: List[GuildExperiment]
guilds: List[Guild] guilds: List[Guild]
merged_members: List[List[MemberWithUser]] merged_members: List[List[MemberWithUser]]
pending_payments: NotRequired[List[Payment]] pending_payments: NotRequired[List[Payment]]

65
discord/utils.py

@ -1520,7 +1520,6 @@ def stream_supports_colour(stream: Any) -> bool:
class _ColourFormatter(logging.Formatter): class _ColourFormatter(logging.Formatter):
# ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher
# It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands
# The important ones here relate to colour. # The important ones here relate to colour.
@ -1620,3 +1619,67 @@ def setup_logging(
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.setLevel(level) logger.setLevel(level)
logger.addHandler(handler) logger.addHandler(handler)
if TYPE_CHECKING:
def murmurhash32(key: Union[bytes, bytearray, memoryview, str], seed: int = 0, *, signed: bool = True) -> int: # type: ignore
pass
else:
try:
from mmh3 import hash as murmurhash32 # Prefer the mmh3 package if available
except ImportError:
# Modified murmurhash3 function from https://github.com/wc-duck/pymmh3/blob/master/pymmh3.py
def murmurhash32(key: Union[bytes, bytearray, memoryview, str], seed: int = 0, *, signed: bool = True) -> int:
key = bytearray(key.encode() if isinstance(key, str) else key)
length = len(key)
nblocks = int(length / 4)
h1 = seed
c1 = 0xCC9E2D51
c2 = 0x1B873593
for block_start in range(0, nblocks * 4, 4):
k1 = (
key[block_start + 3] << 24
| key[block_start + 2] << 16
| key[block_start + 1] << 8
| key[block_start + 0]
)
k1 = (c1 * k1) & 0xFFFFFFFF
k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF
k1 = (c2 * k1) & 0xFFFFFFFF
h1 ^= k1
h1 = (h1 << 13 | h1 >> 19) & 0xFFFFFFFF
h1 = (h1 * 5 + 0xE6546B64) & 0xFFFFFFFF
tail_index = nblocks * 4
k1 = 0
tail_size = length & 3
if tail_size >= 3:
k1 ^= key[tail_index + 2] << 16
if tail_size >= 2:
k1 ^= key[tail_index + 1] << 8
if tail_size >= 1:
k1 ^= key[tail_index + 0]
if tail_size > 0:
k1 = (k1 * c1) & 0xFFFFFFFF
k1 = (k1 << 15 | k1 >> 17) & 0xFFFFFFFF
k1 = (k1 * c2) & 0xFFFFFFFF
h1 ^= k1
unsigned_val = h1 ^ length
unsigned_val ^= unsigned_val >> 16
unsigned_val = (unsigned_val * 0x85EBCA6B) & 0xFFFFFFFF
unsigned_val ^= unsigned_val >> 13
unsigned_val = (unsigned_val * 0xC2B2AE35) & 0xFFFFFFFF
unsigned_val ^= unsigned_val >> 16
if not signed or (unsigned_val & 0x80000000 == 0):
return unsigned_val
else:
return -((unsigned_val ^ 0xFFFFFFFF) + 1)

72
docs/api.rst

@ -5674,6 +5674,40 @@ of :class:`enum.Enum`.
Represents a guild-bound read state for guild onboarding. Only one exists per guild. Represents a guild-bound read state for guild onboarding. Only one exists per guild.
.. class:: ExperimentFilterType
Represents the type of an experiment population filter.
.. versionadded:: 2.1
.. attribute:: feature
The guild must have one of the given features.
.. attribute:: id_range
The guild's ID must be within the given range.
.. attribute:: member_count_range
The guild's member count must be within the given range.
.. attribute:: ids
The guild must be in the given list of IDs.
.. attribute:: hub_type
The guild must be one of the given hub types.
.. attribute:: vanity_url
The guild must or must not have a vanity URL.
.. attribute:: hash_range
The guild's calculated hash must be less than the given value.
.. _discord-api-audit-logs: .. _discord-api-audit-logs:
@ -7741,6 +7775,44 @@ ForumTag
.. autoclass:: ForumTag .. autoclass:: ForumTag
:members: :members:
Experiment
~~~~~~~~~~
.. attributetable:: UserExperiment
.. autoclass:: UserExperiment()
:members:
.. attributetable:: GuildExperiment
.. autoclass:: GuildExperiment()
:members:
.. attributetable:: HoldoutExperiment
.. autoclass:: HoldoutExperiment()
:members:
.. attributetable:: ExperimentOverride
.. autoclass:: ExperimentOverride()
:members:
.. attributetable:: ExperimentPopulation
.. autoclass:: ExperimentPopulation()
:members:
.. attributetable:: ExperimentFilter
.. autoclass:: ExperimentFilter()
:members:
.. attributetable:: ExperimentRollout
.. autoclass:: ExperimentRollout()
:members:
Flags Flags
~~~~~~ ~~~~~~

1
setup.py

@ -47,6 +47,7 @@ extras_require = {
'aiodns>=1.1', 'aiodns>=1.1',
'Brotli', 'Brotli',
'cchardet==2.1.7; python_version < "3.10"', 'cchardet==2.1.7; python_version < "3.10"',
'mmh3>=2.5',
], ],
'test': [ 'test': [
'coverage[toml]', 'coverage[toml]',

Loading…
Cancel
Save