diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index ab61bed6a2..6514a38b3b 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -5,6 +5,9 @@ release. ### Added +- `discord.DiscordTime`, a `datetime.datetime` subclass that offers additional + functionality for snowflakes as well as util methods. + ### Fixed ### Changed @@ -28,3 +31,7 @@ release. - `AsyncIterator.get` use `AsyncIterator.find` with `lambda i: i.attr == val` instead - `utils.as_chunks` use `itertools.batched` on Python 3.12+ or your own implementation instead +- `utils.generate_snowflake`, moved to `discord.datetime.DiscordTime.generate_snowflake` +- `utils.utcnow`, moved to `discord.datetime.DiscordTime.utcnow` +- `utils.snowflake_time`, moved to `DiscordTime.from_snowflake` +- `utils.format_dt`, moved to `DiscordTime.format` diff --git a/discord/__init__.py b/discord/__init__.py index 3b2440411b..8f2922943d 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -40,6 +40,7 @@ from .colour import * from .commands import * from .components import * +from .datetime import * from .embeds import * from .emoji import * from .enums import * diff --git a/discord/activity.py b/discord/activity.py index 1ca56e588d..9be0cf2e69 100644 --- a/discord/activity.py +++ b/discord/activity.py @@ -30,6 +30,7 @@ from .asset import Asset from .colour import Colour +from .datetime import DiscordTime from .enums import ActivityType, try_enum from .partial_emoji import PartialEmoji from .utils.private import get_as_snowflake @@ -123,13 +124,14 @@ def __init__(self, **kwargs): self._created_at: float | None = kwargs.pop("created_at", None) @property - def created_at(self) -> datetime.datetime | None: + def created_at(self) -> DiscordTime | None: """When the user started doing this activity in UTC. .. versionadded:: 1.3 """ if self._created_at is not None: - return datetime.datetime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc) + return None def to_dict(self) -> ActivityPayload: raise NotImplementedError @@ -273,24 +275,24 @@ def to_dict(self) -> dict[str, Any]: return ret @property - def start(self) -> datetime.datetime | None: + def start(self) -> DiscordTime | None: """When the user started doing this activity in UTC, if applicable.""" try: timestamp = self.timestamps["start"] / 1000 except KeyError: return None else: - return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) @property - def end(self) -> datetime.datetime | None: + def end(self) -> DiscordTime | None: """When the user will stop doing this activity in UTC, if applicable.""" try: timestamp = self.timestamps["end"] / 1000 except KeyError: return None else: - return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) @property def large_image_url(self) -> str | None: @@ -387,17 +389,17 @@ def type(self) -> ActivityType: return ActivityType.playing @property - def start(self) -> datetime.datetime | None: + def start(self) -> DiscordTime | None: """When the user started playing this game in UTC, if applicable.""" if self._start: - return datetime.datetime.fromtimestamp(self._start / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._start / 1000, tz=datetime.timezone.utc) return None @property - def end(self) -> datetime.datetime | None: + def end(self) -> DiscordTime | None: """When the user will stop playing this game in UTC, if applicable.""" if self._end: - return datetime.datetime.fromtimestamp(self._end / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._end / 1000, tz=datetime.timezone.utc) return None def __str__(self) -> str: @@ -583,13 +585,14 @@ def type(self) -> ActivityType: return ActivityType.listening @property - def created_at(self) -> datetime.datetime | None: + def created_at(self) -> DiscordTime | None: """When the user started listening in UTC. .. versionadded:: 1.3 """ if self._created_at is not None: - return datetime.datetime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._created_at / 1000, tz=datetime.timezone.utc) + return None @property def colour(self) -> Colour: @@ -689,14 +692,14 @@ def track_url(self) -> str: return f"https://open.spotify.com/track/{self.track_id}" @property - def start(self) -> datetime.datetime: + def start(self) -> DiscordTime: """When the user started playing this song in UTC.""" - return datetime.datetime.fromtimestamp(self._timestamps["start"] / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._timestamps["start"] / 1000, tz=datetime.timezone.utc) @property - def end(self) -> datetime.datetime: + def end(self) -> DiscordTime: """When the user will stop playing this song in UTC.""" - return datetime.datetime.fromtimestamp(self._timestamps["end"] / 1000, tz=datetime.timezone.utc) + return DiscordTime.fromtimestamp(self._timestamps["end"] / 1000, tz=datetime.timezone.utc) @property def duration(self) -> datetime.timedelta: diff --git a/discord/app/state.py b/discord/app/state.py index 1ca3b13bfe..0d10dd7cec 100644 --- a/discord/app/state.py +++ b/discord/app/state.py @@ -71,7 +71,7 @@ from ..ui.modal import Modal from ..ui.view import View from ..user import ClientUser, User -from ..utils.private import get_as_snowflake, parse_time, sane_wait_for +from ..utils.private import get_as_snowflake, sane_wait_for from .cache import Cache from .event_emitter import EventEmitter diff --git a/discord/audit_logs.py b/discord/audit_logs.py index a59efed1e9..fb5baa74ae 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -30,10 +30,11 @@ from inspect import isawaitable from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, TypeVar -from . import enums, utils +from . import enums from .asset import Asset from .automod import AutoModAction, AutoModTriggerMetadata from .colour import Colour +from .datetime import DiscordTime from .invite import Invite from .mixins import Hashable from .object import Object @@ -585,9 +586,9 @@ def __repr__(self) -> str: return f"" @cached_property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the entry's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) async def get_target( self, diff --git a/discord/channel/base.py b/discord/channel/base.py index f446833eee..05aa0fb80a 100644 --- a/discord/channel/base.py +++ b/discord/channel/base.py @@ -34,13 +34,14 @@ from typing_extensions import Self, TypeVar, override from ..abc import Messageable, Snowflake, SnowflakeTime, User, _Overwrites, _purge_messages_helper +from ..datetime import DiscordTime from ..emoji import GuildEmoji, PartialEmoji from ..enums import ChannelType, InviteTarget, SortOrder, try_enum from ..errors import ClientException from ..flags import ChannelFlags, MessageFlags from ..iterators import ArchivedThreadIterator from ..mixins import Hashable -from ..utils import MISSING, Undefined, find, snowflake_time +from ..utils import MISSING, Undefined, find from ..utils.private import SnowflakeList, bytes_to_base64_data, copy_doc, get_as_snowflake if TYPE_CHECKING: @@ -114,9 +115,9 @@ async def _get_channel(self) -> Self: return self @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """The channel's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @abstractmethod @override diff --git a/discord/channel/thread.py b/discord/channel/thread.py index 3a942dff86..2b8ac80105 100644 --- a/discord/channel/thread.py +++ b/discord/channel/thread.py @@ -32,6 +32,7 @@ from discord import utils from ..abc import Messageable, _purge_messages_helper +from ..datetime import DiscordTime from ..enums import ( ChannelType, try_enum, @@ -42,7 +43,7 @@ from ..mixins import Hashable from ..types.threads import Thread as ThreadPayload from ..utils import MISSING -from ..utils.private import get_as_snowflake, parse_time +from ..utils.private import get_as_snowflake from .base import BaseChannel, GuildMessageableChannel __all__ = ( @@ -128,9 +129,9 @@ class Thread(BaseChannel[ThreadPayload], GuildMessageableChannel): auto_archive_duration: int The duration in minutes until the thread is automatically archived due to inactivity. Usually a value of 60, 1440, 4320 and 10080. - archive_timestamp: datetime.datetime + archive_timestamp: discord.DiscordTime An aware timestamp of when the thread's archived status was last updated in UTC. - created_at: datetime.datetime | None + created_at: discord.DiscordTime | None An aware timestamp of when the thread was created. Only available for threads created after 2022-01-09. flags: ChannelFlags @@ -205,10 +206,10 @@ async def _update(self, data: ThreadPayload) -> None: metadata = data["thread_metadata"] self.archived: bool = metadata["archived"] self.auto_archive_duration: int = metadata["auto_archive_duration"] - self.archive_timestamp = parse_time(metadata["archive_timestamp"]) + self.archive_timestamp: DiscordTime = DiscordTime.parse_time(metadata["archive_timestamp"]) self.locked: bool = metadata["locked"] self.invitable: bool = metadata.get("invitable", True) - self.created_at = parse_time(metadata.get("create_timestamp")) + self.created_at: DiscordTime | None = DiscordTime.parse_time(metadata.get("create_timestamp")) # Handle thread member data if "member" in data: @@ -856,7 +857,7 @@ class ThreadMember(Hashable): The thread member's ID. thread_id: :class:`int` The thread's ID. - joined_at: :class:`datetime.datetime` + joined_at: :class:`discord.DiscordTime` The time the member joined the thread in UTC. """ @@ -889,7 +890,7 @@ def _from_data(self, data: ThreadMemberPayload): except KeyError: self.thread_id = self.parent.id - self.joined_at = parse_time(data["join_timestamp"]) + self.joined_at: DiscordTime | None = DiscordTime.parse_time(data["join_timestamp"]) self.flags = data["flags"] @property diff --git a/discord/commands/core.py b/discord/commands/core.py index acbc2a757f..2ef81ee74f 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -49,6 +49,7 @@ from ..channel import PartialMessageable, _threaded_guild_channel_factory from ..channel.thread import Thread +from ..datetime import DiscordTime from ..enums import Enum as DiscordEnum from ..enums import ( IntegrationType, @@ -70,7 +71,7 @@ from ..object import Object from ..role import Role from ..user import User -from ..utils import MISSING, find, utcnow +from ..utils import MISSING, find from ..utils.private import async_all, maybe_awaitable, warn_deprecated from .context import ApplicationContext, AutocompleteContext from .options import Option, OptionChoice @@ -376,7 +377,7 @@ def is_on_cooldown(self, ctx: ApplicationContext) -> bool: return False bucket = self._buckets.get_bucket(ctx) # type: ignore - current = utcnow().timestamp() + current = DiscordTime.utcnow().timestamp() return bucket.get_tokens(current) == 0 def reset_cooldown(self, ctx: ApplicationContext) -> None: @@ -411,7 +412,7 @@ def get_cooldown_retry_after(self, ctx: ApplicationContext) -> float: """ if self._buckets.valid: bucket = self._buckets.get_bucket(ctx) # type: ignore - current = utcnow().timestamp() + current = DiscordTime.utcnow().timestamp() return bucket.get_retry_after(current) return 0.0 diff --git a/discord/datetime.py b/discord/datetime.py new file mode 100644 index 0000000000..28fb080bf5 --- /dev/null +++ b/discord/datetime.py @@ -0,0 +1,215 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Pycord Development + +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 + +import datetime +from typing import Literal + +from typing_extensions import Self, overload, override + +DISCORD_EPOCH = 1420070400000 +TimestampStyle = Literal["f", "F", "d", "D", "t", "T", "R"] + + +class DiscordTime(datetime.datetime): + """A subclass of :class:`datetime.datetime` that offers additional utility methods + + .. versionadded:: 3.0 + """ + + @override + @classmethod + def utcnow(cls) -> Self: + """A helper function to return an aware UTC datetime representing the current time. + + This should be preferred to :meth:`datetime.datetime.utcnow` since it is an aware + datetime, compared to the naive datetime in the standard library. + + Returns + ------- + :class:`discord.DiscordTime` + The current aware datetime in UTC. + """ + return cls.now(datetime.timezone.utc) + + def generate_snowflake( + self, + *, + mode: Literal["boundary", "realistic"] = "boundary", + high: bool = False, + ) -> int: + """Returns a numeric snowflake pretending to be created at the given date. + + This function can generate both realistic snowflakes (for general use) and + boundary snowflakes (for range queries). + + Parameters + ---------- + mode: :class:`str` + The type of snowflake to generate: + - "realistic": Creates a snowflake with random-like lower bits + - "boundary": Creates a snowflake for range queries (default) + high: :class:`bool` + Only used when mode="boundary". Whether to set the lower 22 bits + to high (True) or low (False). Default is False. + + Returns + ------- + :class:`int` + The snowflake representing the time given. + + Examples + -------- + .. code-block:: python + + # Generate realistic snowflake + snowflake = DateTime.utcnow().generate_snowflake() + + # Generate boundary snowflakes + lower_bound = DateTime.utcnow().generate_snowflake(mode="boundary", high=False) + upper_bound = DateTime.utcnow().generate_snowflake(mode="boundary", high=True) + + # For inclusive ranges: + # Lower: + DateTime.utcnow().generate_snowflake(mode="boundary", high=False) - 1 + # Upper: + DateTime.utcnow().generate_snowflake(mode="boundary", high=True) + 1 + + """ + discord_millis = int(self.timestamp() * 1000 - DISCORD_EPOCH) + + if mode == "realistic": + return (discord_millis << 22) | 0x3FFFFF + elif mode == "boundary": + return (discord_millis << 22) + (2**22 - 1 if high else 0) + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") + + @classmethod + def from_datetime(cls, dt: datetime.datetime | datetime.time) -> Self: + """Converts a datetime or time object to a UTC-aware datetime object. + + Parameters + ---------- + dt: :class:`datetime.datetime` | :class:`datetime.time` + A datetime or time object to generate a DiscordTime from. + """ + if isinstance(dt, datetime.time): + dt = datetime.datetime.combine(cls.utcnow(), dt) + return cls( + day=dt.day, + month=dt.month, + year=dt.year, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + microsecond=dt.microsecond, + tzinfo=dt.tzinfo, + ) + + @classmethod + def from_snowflake(cls, id: int) -> Self: + """Converts a Discord snowflake ID to a UTC-aware datetime object. + + Parameters + ---------- + id: :class:`int` + The snowflake ID. + + Returns + ------- + :class:`discord.DiscordTime` + An aware datetime in UTC representing the creation time of the snowflake. + """ + timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 + return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) + + def format(self, /, style: TimestampStyle | None = None) -> str: + """A method to format this :class:`discord.DiscordTime` for presentation within Discord. + + This allows for a locale-independent way of presenting data using Discord specific Markdown. + + +-------------+----------------------------+-----------------+ + | Style | Example Output | Description | + +=============+============================+=================+ + | t | 22:57 | Short Time | + +-------------+----------------------------+-----------------+ + | T | 22:57:58 | Long Time | + +-------------+----------------------------+-----------------+ + | d | 17/05/2016 | Short Date | + +-------------+----------------------------+-----------------+ + | D | 17 May 2016 | Long Date | + +-------------+----------------------------+-----------------+ + | f (default) | 17 May 2016 22:57 | Short Date Time | + +-------------+----------------------------+-----------------+ + | F | Tuesday, 17 May 2016 22:57 | Long Date Time | + +-------------+----------------------------+-----------------+ + | R | 5 years ago | Relative Time | + +-------------+----------------------------+-----------------+ + + Note that the exact output depends on the user's locale setting in the client. The example output + presented is using the ``en-GB`` locale. + + .. versionadded:: 2.0 + + Parameters + ---------- + style: :class:`str` + The style to format the datetime with. + + Returns + ------- + :class:`str` + The formatted string. + """ + if style is None: + return f"" + return f"" + + @overload + @classmethod + def parse_time(cls, timestamp: None) -> None: ... + + @overload + @classmethod + def parse_time(cls, timestamp: str) -> DiscordTime: ... + + @classmethod + def parse_time(cls, timestamp: str | None) -> DiscordTime | None: + """A helper function to convert an ISO 8601 timestamp to a discord datetime object. + + Parameters + ---------- + timestamp: Optional[:class:`str`] + The timestamp to convert. + + Returns + ------- + Optional[:class:`discord.DiscordTime`] + The converted datetime object. + """ + if timestamp: + return DiscordTime.fromisoformat(timestamp) + return None diff --git a/discord/embeds.py b/discord/embeds.py index 9e5ca1c523..0f45272ed5 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -28,9 +28,8 @@ import datetime from typing import TYPE_CHECKING, Any, Mapping, TypeVar -from . import utils from .colour import Colour -from .utils.private import parse_time +from .datetime import DiscordTime __all__ = ( "Embed", @@ -324,7 +323,7 @@ class Embed: url: :class:`str` The URL of the embed. This can be set during initialisation. - timestamp: :class:`datetime.datetime` + timestamp: :class:`discord.DiscordTime` The timestamp of the embed content. This is an aware datetime. If a naive datetime is passed, it is converted to an aware datetime with the local timezone. @@ -380,8 +379,7 @@ def __init__( if self.url: self.url = str(self.url) - if timestamp: - self.timestamp = timestamp + self.timestamp: DiscordTime | None = DiscordTime.from_datetime(timestamp) if timestamp else None self._fields: list[EmbedField] = fields if fields is not None else [] @@ -437,10 +435,10 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: except KeyError: pass - try: - self._timestamp = parse_time(data["timestamp"]) - except KeyError: - pass + if timestamp := data.get("timestamp"): + self._timestamp: DiscordTime | None = DiscordTime.parse_time(timestamp) + else: + self._timestamp = None for attr in ( "thumbnail", @@ -533,15 +531,15 @@ def colour(self, value: int | Colour | None): # type: ignore color = colour @property - def timestamp(self) -> datetime.datetime | None: - return getattr(self, "_timestamp", None) + def timestamp(self) -> DiscordTime | None: + return self._timestamp @timestamp.setter def timestamp(self, value: datetime.datetime | None): if isinstance(value, datetime.datetime): if value.tzinfo is None: value = value.astimezone() - self._timestamp = value + self._timestamp = DiscordTime.from_datetime(value) elif value is None: self._timestamp = value else: diff --git a/discord/emoji.py b/discord/emoji.py index bfe44e8d4d..29fa2824e0 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -28,9 +28,10 @@ from typing import TYPE_CHECKING, Any, Iterator from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .partial_emoji import PartialEmoji, _EmojiTag from .user import User -from .utils import MISSING, Undefined, snowflake_time +from .utils import MISSING, Undefined from .utils.private import SnowflakeList __all__ = ( @@ -40,8 +41,6 @@ ) if TYPE_CHECKING: - from datetime import datetime - from .abc import Snowflake from .app.state import ConnectionState from .guild import Guild @@ -100,9 +99,9 @@ def __hash__(self) -> int: return self.id >> 22 @property - def created_at(self) -> datetime: + def created_at(self) -> DiscordTime: """Returns the emoji's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def url(self) -> str: diff --git a/discord/events/channel.py b/discord/events/channel.py index 5ae6255826..21616f56a3 100644 --- a/discord/events/channel.py +++ b/discord/events/channel.py @@ -23,7 +23,6 @@ """ from copy import copy -from datetime import datetime from functools import lru_cache from typing import Any, TypeVar, cast @@ -35,7 +34,9 @@ from discord.channel import GroupChannel, GuildChannel, _channel_factory from discord.channel.thread import Thread from discord.enums import ChannelType, try_enum -from discord.utils.private import get_as_snowflake, parse_time +from discord.utils.private import get_as_snowflake + +from ..datetime import DiscordTime T = TypeVar("T") @@ -257,13 +258,13 @@ class ChannelPinsUpdate(Event): ---------- channel: :class:`abc.PrivateChannel` | :class:`TextChannel` | :class:`VoiceChannel` | :class:`StageChannel` | :class:`ForumChannel` | :class:`Thread` The channel that had its pins updated. Can be any messageable channel type. - last_pin: :class:`datetime.datetime` | None + last_pin: :class:`discord.DiscordTime` | None The latest message that was pinned as an aware datetime in UTC, or None if no pins exist. """ __event_name__: str = "CHANNEL_PINS_UPDATE" channel: PrivateChannel | GuildChannel | Thread - last_pin: datetime | None + last_pin: DiscordTime | None @classmethod @override @@ -282,5 +283,5 @@ async def __load__(cls, data: dict[str, Any], state: ConnectionState) -> Self | self = cls() self.channel = channel - self.last_pin = parse_time(data["last_pin_timestamp"]) if data["last_pin_timestamp"] else None + self.last_pin = DiscordTime.parse_time(data["last_pin_timestamp"]) return self diff --git a/discord/events/typing.py b/discord/events/typing.py index 3ab879ce2b..31ffcd6704 100644 --- a/discord/events/typing.py +++ b/discord/events/typing.py @@ -22,7 +22,6 @@ DEALINGS IN THE SOFTWARE. """ -from datetime import datetime from typing import TYPE_CHECKING, Any from typing_extensions import Self, override @@ -36,6 +35,8 @@ from discord.raw_models import RawTypingEvent from discord.user import User +from ..datetime import DiscordTime + if TYPE_CHECKING: from discord.message import MessageableChannel @@ -59,7 +60,7 @@ class TypingStart(Event): The location where the typing originated from. user: :class:`User` | :class:`Member` The user that started typing. - when: :class:`datetime.datetime` + when: :class:`discord.DiscordTime` When the typing started as an aware datetime in UTC. """ @@ -68,7 +69,7 @@ class TypingStart(Event): raw: RawTypingEvent channel: "MessageableChannel" user: User | Member - when: datetime + when: DiscordTime @classmethod @override diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 9ecbd250ed..dda6ad07ed 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -41,6 +41,8 @@ from discord.backoff import ExponentialBackoff from discord.utils import MISSING +from ...datetime import DiscordTime + __all__ = ("loop",) T = TypeVar("T") @@ -323,7 +325,7 @@ def current_loop(self) -> int: return _current_loop_ctx.get() if _current_loop_ctx.get() is not None else self._current_loop @property - def next_iteration(self) -> datetime.datetime | None: + def next_iteration(self) -> DiscordTime | None: """When the next iteration of the loop will occur. .. versionadded:: 1.3 diff --git a/discord/guild.py b/discord/guild.py index 413dffc20b..8e55c3cdba 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -27,7 +27,6 @@ import asyncio import copy -import datetime import unicodedata from typing import ( TYPE_CHECKING, @@ -53,6 +52,7 @@ from .channel import _guild_channel_factory, _threaded_guild_channel_factory from .channel.thread import Thread, ThreadMember from .colour import Colour +from .datetime import DiscordTime from .emoji import GuildEmoji, PartialEmoji, _EmojiTag from .enums import ( AuditLogAction, @@ -1259,9 +1259,9 @@ def shard_id(self) -> int: return (self.id >> 22) % count @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the guild's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def invites_disabled(self) -> bool: diff --git a/discord/incidents.py b/discord/incidents.py index 5bf41485c8..9503645236 100644 --- a/discord/incidents.py +++ b/discord/incidents.py @@ -24,10 +24,9 @@ from __future__ import annotations -import datetime from typing import TYPE_CHECKING -from .utils.private import parse_time +from .datetime import DiscordTime if TYPE_CHECKING: from .types.guild import IncidentsData as IncidentsDataPayload @@ -40,13 +39,13 @@ class IncidentsData: Attributes ---------- - invites_disabled_until: Optional[datetime.datetime] - When invites will be enabled again as a :class:`datetime.datetime`, or ``None``. - dms_disabled_until: Optional[datetime.datetime] - When direct messages will be enabled again as a :class:`datetime.datetime`, or ``None``. - dm_spam_detected_at: Optional[datetime.datetime] + invites_disabled_until: Optional[discord.DiscordTime] + When invites will be enabled again as a :class:`discord.DiscordTime`, or ``None``. + dms_disabled_until: Optional[discord.DiscordTime] + When direct messages will be enabled again as a :class:`discord.DiscordTime`, or ``None``. + dm_spam_detected_at: Optional[discord.DiscordTime] When DM spam was detected, or ``None``. - raid_detected_at: Optional[datetime.datetime] + raid_detected_at: Optional[discord.Discordtime] When a raid was detected, or ``None``. """ @@ -58,13 +57,13 @@ class IncidentsData: ) def __init__(self, data: IncidentsDataPayload): - self.invites_disabled_until: datetime.datetime | None = parse_time(data.get("invites_disabled_until")) + self.invites_disabled_until: DiscordTime | None = DiscordTime.parse_time(data.get("invites_disabled_until")) - self.dms_disabled_until: datetime.datetime | None = parse_time(data.get("dms_disabled_until")) + self.dms_disabled_until: DiscordTime | None = DiscordTime.parse_time(data.get("dms_disabled_until")) - self.dm_spam_detected_at: datetime.datetime | None = parse_time(data.get("dm_spam_detected_at")) + self.dm_spam_detected_at: DiscordTime | None = DiscordTime.parse_time(data.get("dm_spam_detected_at")) - self.raid_detected_at: datetime.datetime | None = parse_time(data.get("raid_detected_at")) + self.raid_detected_at: DiscordTime | None = DiscordTime.parse_time(data.get("raid_detected_at")) def to_dict(self) -> IncidentsDataPayload: return { diff --git a/discord/integrations.py b/discord/integrations.py index ac64f2d3d6..fcba3e0e35 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -30,11 +30,12 @@ from discord import utils +from .datetime import DiscordTime from .enums import ExpireBehaviour, try_enum from .errors import InvalidArgument from .user import User from .utils import MISSING -from .utils.private import get_as_snowflake, parse_time +from .utils.private import get_as_snowflake __all__ = ( "IntegrationAccount", @@ -188,7 +189,7 @@ class StreamIntegration(Integration): The user for the integration. account: :class:`IntegrationAccount` The integration account information. - synced_at: :class:`datetime.datetime` + synced_at: :class:`discord.DiscordTime` An aware UTC datetime representing when the integration was last synced. """ @@ -208,7 +209,7 @@ def _from_data(self, data: StreamIntegrationPayload) -> None: self.revoked: bool = data["revoked"] self.expire_behaviour: ExpireBehaviour = try_enum(ExpireBehaviour, data["expire_behavior"]) self.expire_grace_period: int = data["expire_grace_period"] - self.synced_at: datetime.datetime = parse_time(data["synced_at"]) + self.synced_at: DiscordTime = DiscordTime.parse_time(data["synced_at"]) self._role_id: int | None = get_as_snowflake(data, "role_id") self.syncing: bool = data["syncing"] self.enable_emoticons: bool = data["enable_emoticons"] @@ -289,7 +290,7 @@ async def sync(self) -> None: Syncing the integration failed. """ await self._state.http.sync_integration(self.guild.id, self.id) - self.synced_at = datetime.datetime.now(datetime.timezone.utc) + self.synced_at = DiscordTime.now(datetime.timezone.utc) class IntegrationApplication: diff --git a/discord/interactions.py b/discord/interactions.py index 6bd161a637..64904b6f97 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -26,13 +26,13 @@ from __future__ import annotations import asyncio -import datetime from typing import TYPE_CHECKING, Any, Coroutine, Generic, Union from typing_extensions import Self, TypeVar, override, reveal_type from . import utils from .channel import ChannelType, PartialMessageable, _threaded_channel_factory +from .datetime import DiscordTime from .enums import ( InteractionContextType, InteractionResponseType, @@ -335,9 +335,9 @@ async def get_guild(self) -> Guild | None: return self._state and await self._state._get_guild(self.guild_id) @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the interaction's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) def is_command(self) -> bool: """Indicates whether the interaction is an application command.""" diff --git a/discord/invite.py b/discord/invite.py index 5c3a4e7311..712dfdd0c4 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -29,11 +29,11 @@ from .appinfo import PartialAppInfo from .asset import Asset +from .datetime import DiscordTime from .enums import ChannelType, InviteTarget, VerificationLevel, try_enum from .mixins import Hashable from .object import Object -from .utils import snowflake_time -from .utils.private import get_as_snowflake, parse_time +from .utils.private import get_as_snowflake __all__ = ( "PartialInviteChannel", @@ -57,8 +57,6 @@ InviteGuildType = Guild | "PartialInviteGuild" | Object InviteChannelType = GuildChannel | "PartialInviteChannel" | Object - import datetime - class PartialInviteChannel: """Represents a "partial" invite channel. @@ -113,9 +111,9 @@ def mention(self) -> str: return f"<#{self.id}>" @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the channel's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) class PartialInviteGuild: @@ -189,9 +187,9 @@ def __repr__(self) -> str: ) @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the guild's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def icon(self) -> Asset | None: @@ -277,7 +275,7 @@ class Invite(Hashable): The guild the invite is for. Can be ``None`` if it's from a group direct message. revoked: :class:`bool` Indicates if the invite has been revoked. - created_at: :class:`datetime.datetime` + created_at: :class:`discord.DiscordTime` An aware UTC datetime object denoting the time the invite was created. temporary: :class:`bool` Indicates that the invite grants temporary membership. @@ -294,7 +292,7 @@ class Invite(Hashable): approximate_presence_count: Optional[:class:`int`] The approximate number of members currently active in the guild. This includes idle, dnd, online, and invisible members. Offline members are excluded. - expires_at: Optional[:class:`datetime.datetime`] + expires_at: Optional[:class:`discord.DiscordTime`] The expiration date of the invite. If the value is ``None`` when received through `Client.fetch_invite` with `with_expiration` enabled, the invite will never expire. @@ -356,7 +354,7 @@ def __init__( self.code: str = data["code"] self.guild: InviteGuildType | None = self._resolve_guild(data.get("guild"), guild) self.revoked: bool | None = data.get("revoked") - self.created_at: datetime.datetime | None = parse_time(data.get("created_at")) + self.created_at: DiscordTime | None = DiscordTime.parse_time(data.get("created_at")) self.temporary: bool | None = data.get("temporary") self.uses: int | None = data.get("uses") self.max_uses: int | None = data.get("max_uses") @@ -364,7 +362,7 @@ def __init__( self.approximate_member_count: int | None = data.get("approximate_member_count") expires_at = data.get("expires_at", None) - self.expires_at: datetime.datetime | None = parse_time(expires_at) if expires_at else None + self.expires_at: DiscordTime | None = DiscordTime.parse_time(expires_at) if expires_at else None inviter_data = data.get("inviter") self.inviter: User | None = None if inviter_data is None else self._state.create_user(inviter_data) diff --git a/discord/iterators.py b/discord/iterators.py index 994469ab35..a46bd5aa63 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -40,9 +40,9 @@ ) from .audit_logs import AuditLogEntry +from .datetime import DiscordTime from .errors import NoMoreItems from .object import Object -from .utils import generate_snowflake, snowflake_time from .utils.private import maybe_awaitable, warn_deprecated __all__ = ( @@ -331,11 +331,11 @@ def __init__( oldest_first=None, ): if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) if isinstance(around, datetime.datetime): - around = Object(id=generate_snowflake(around)) + around = Object(id=DiscordTime.from_datetime(around).generate_snowflake()) self.reverse = after is not None if oldest_first is None else oldest_first self.messageable = messageable @@ -457,9 +457,9 @@ def __init__( action_type=None, ): if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.guild = guild self.loop = guild._state.loop @@ -568,9 +568,9 @@ class GuildIterator(_AsyncIterator["Guild"]): def __init__(self, bot, limit, before=None, after=None, with_counts=True): if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.bot = bot self.limit = limit @@ -655,7 +655,7 @@ async def _retrieve_guilds_after_strategy(self, retrieve): class MemberIterator(_AsyncIterator["Member"]): def __init__(self, guild, limit=1000, after=None): if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.guild = guild self.limit = limit @@ -787,14 +787,14 @@ def __init__( self.before = None elif isinstance(before, datetime.datetime): if joined: - self.before = str(generate_snowflake(before, high=False)) + self.before = str(DiscordTime.from_datetime(before).generate_snowflake(high=False)) else: self.before = before.isoformat() else: if joined: self.before = str(before.id) else: - self.before = snowflake_time(before.id).isoformat() + self.before = DiscordTime.from_snowflake(before.id).isoformat() self.update_before: Callable[[ThreadPayload], str] = self.get_archive_timestamp @@ -863,9 +863,9 @@ def __init__( after: datetime.datetime | int | None = None, ): if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.event = event self.limit = limit @@ -957,9 +957,9 @@ def __init__( self.sku_ids = sku_ids if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.before = before self.after = after @@ -1071,9 +1071,9 @@ def __init__( user_id: int | None = None, ): if isinstance(before, datetime.datetime): - before = Object(id=generate_snowflake(before, high=False)) + before = Object(id=DiscordTime.from_datetime(before).generate_snowflake(high=False)) if isinstance(after, datetime.datetime): - after = Object(id=generate_snowflake(after, high=True)) + after = Object(id=DiscordTime.from_datetime(after).generate_snowflake(high=True)) self.state = state self.sku_id = sku_id @@ -1179,7 +1179,7 @@ def __init__( elif isinstance(before, datetime.datetime): self.before = before.isoformat() else: - self.before = snowflake_time(before.id).isoformat() + self.before = DiscordTime.from_snowflake(before.id).isoformat() self.update_before: Callable[[MessagePinPayload], str] = self.get_last_pinned diff --git a/discord/member.py b/discord/member.py index 04f69246af..b7a8710fcf 100644 --- a/discord/member.py +++ b/discord/member.py @@ -40,6 +40,7 @@ from .activity import ActivityTypes, create_activity from .asset import Asset from .colour import Colour +from .datetime import DiscordTime from .enums import Status, try_enum from .errors import InvalidArgument from .flags import MemberFlags @@ -48,7 +49,7 @@ from .primary_guild import PrimaryGuild from .user import BaseUser, User, _UserTag from .utils import MISSING -from .utils.private import SnowflakeList, copy_doc, parse_time +from .utils.private import SnowflakeList, copy_doc __all__ = ( "VoiceState", @@ -101,7 +102,7 @@ class VoiceState: .. versionadded:: 1.7 - requested_to_speak_at: Optional[:class:`datetime.datetime`] + requested_to_speak_at: Optional[:class:`discord.DiscordTime`] An aware datetime object that specifies the date and time in UTC that the member requested to speak. It will be ``None`` if they are not requesting to speak anymore or have been accepted to speak. @@ -153,7 +154,7 @@ def _update( self.mute: bool = data.get("mute", False) self.deaf: bool = data.get("deaf", False) self.suppress: bool = data.get("suppress", False) - self.requested_to_speak_at: datetime.datetime | None = parse_time(data.get("request_to_speak_timestamp")) + self.requested_to_speak_at: DiscordTime | None = DiscordTime.parse_time(data.get("request_to_speak_timestamp")) self.channel: VocalGuildChannel | None = channel def __repr__(self) -> str: @@ -244,7 +245,7 @@ class Member(discord.abc.Messageable, _UserTag): Attributes ---------- - joined_at: Optional[:class:`datetime.datetime`] + joined_at: Optional[:class:`discord.DiscordTime`] An aware datetime object that specifies the date and time in UTC that the member joined the guild. If the member left and rejoined the guild, this will be the latest date. In certain cases, this can be ``None``. activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]] @@ -264,10 +265,10 @@ class Member(discord.abc.Messageable, _UserTag): Whether the member is pending member verification. .. versionadded:: 1.6 - premium_since: Optional[:class:`datetime.datetime`] + premium_since: Optional[:class:`discord.DiscordTime`] An aware datetime object that specifies the date and time in UTC when the member used their "Nitro boost" on the guild, if available. This could be ``None``. - communication_disabled_until: Optional[:class:`datetime.datetime`] + communication_disabled_until: Optional[:class:`discord.DiscordTime`] An aware datetime object that specifies the date and time in UTC when the member will be removed from timeout. .. versionadded:: 2.0 @@ -300,7 +301,7 @@ class Member(discord.abc.Messageable, _UserTag): discriminator: str bot: bool system: bool - created_at: datetime.datetime + created_at: DiscordTime default_avatar: Asset avatar: Asset | None dm_channel: DMChannel | None @@ -310,14 +311,14 @@ class Member(discord.abc.Messageable, _UserTag): banner: Asset | None accent_color: Colour | None accent_colour: Colour | None - communication_disabled_until: datetime.datetime | None + communication_disabled_until: DiscordTime | None primary_guild: PrimaryGuild | None def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state self.guild: Guild = guild - self.joined_at: datetime.datetime | None = parse_time(data.get("joined_at")) - self.premium_since: datetime.datetime | None = parse_time(data.get("premium_since")) + self.joined_at: DiscordTime | None = DiscordTime.parse_time(data.get("joined_at")) + self.premium_since: DiscordTime | None = DiscordTime.parse_time(data.get("premium_since")) self._roles: SnowflakeList = SnowflakeList(map(int, data["roles"])) self._client_status: dict[str | None, str] = {None: "offline"} self.activities: tuple[ActivityTypes, ...] = () @@ -325,7 +326,7 @@ def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: Connecti self.pending: bool = data.get("pending", False) self._avatar: str | None = data.get("avatar") self._banner: str | None = data.get("banner") - self.communication_disabled_until: datetime.datetime | None = parse_time( + self.communication_disabled_until: DiscordTime | None = DiscordTime.parse_time( data.get("communication_disabled_until") ) self.flags: MemberFlags = MemberFlags._from_value(data.get("flags", 0)) @@ -372,8 +373,8 @@ def _from_message(cls: type[M], *, message: Message, data: MemberPayload) -> M: return cls(data=data, guild=message.guild, state=message._state) # type: ignore def _update_from_message(self, data: MemberPayload) -> None: - self.joined_at = parse_time(data.get("joined_at")) - self.premium_since = parse_time(data.get("premium_since")) + self.joined_at = DiscordTime.parse_time(data.get("joined_at")) + self.premium_since = DiscordTime.parse_time(data.get("premium_since")) self._roles = SnowflakeList(map(int, data["roles"])) self.nick = data.get("nick", None) self.pending = data.get("pending", False) @@ -435,11 +436,11 @@ def _update(self, data: MemberPayload) -> None: except KeyError: pass - self.premium_since = parse_time(data.get("premium_since")) + self.premium_since = DiscordTime.parse_time(data.get("premium_since")) self._roles = SnowflakeList(map(int, data["roles"])) self._avatar = data.get("avatar") self._banner = data.get("banner") - self.communication_disabled_until = parse_time(data.get("communication_disabled_until")) + self.communication_disabled_until = DiscordTime.parse_time(data.get("communication_disabled_until")) self.flags = MemberFlags._from_value(data.get("flags", 0)) def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> tuple[User, User] | None: diff --git a/discord/message.py b/discord/message.py index caae31278f..088de573a8 100644 --- a/discord/message.py +++ b/discord/message.py @@ -25,7 +25,6 @@ from __future__ import annotations -import datetime import io import re from inspect import isawaitable @@ -49,6 +48,7 @@ from .channel import PartialMessageable from .channel.thread import Thread from .components import _component_factory +from .datetime import DiscordTime from .embeds import Embed from .emoji import AppEmoji, GuildEmoji from .enums import ChannelType, MessageReferenceType, MessageType, try_enum @@ -64,7 +64,7 @@ from .reaction import Reaction from .sticker import StickerItem from .utils import MISSING, escape_mentions -from .utils.private import cached_slot_property, delay_task, get_as_snowflake, parse_time, warn_deprecated +from .utils.private import cached_slot_property, delay_task, get_as_snowflake, warn_deprecated if TYPE_CHECKING: from .abc import ( @@ -265,18 +265,18 @@ def __init__(self, *, data: AttachmentPayload, state: ConnectionState): setattr(self, attr, value) @property - def expires_at(self) -> datetime.datetime | None: + def expires_at(self) -> DiscordTime | None: """This attachment URL's expiry time in UTC.""" if not self._ex: return None - return datetime.datetime.utcfromtimestamp(int(self._ex, 16)) + return DiscordTime.utcfromtimestamp(int(self._ex, 16)) @property - def issued_at(self) -> datetime.datetime | None: + def issued_at(self) -> DiscordTime | None: """The attachment URL's issue time in UTC.""" if not self._is: return None - return datetime.datetime.utcfromtimestamp(int(self._is, 16)) + return DiscordTime.utcfromtimestamp(int(self._is, 16)) def is_spoiler(self) -> bool: """Whether this attachment contains a spoiler.""" @@ -694,7 +694,7 @@ class MessageCall: def __init__(self, state: ConnectionState, data: MessageCallPayload): self._state: ConnectionState = state self._participants: SnowflakeList = data.get("participants", []) - self._ended_timestamp: datetime.datetime | None = parse_time(data["ended_timestamp"]) + self._ended_timestamp: DiscordTime | None = DiscordTime.parse_time(data["ended_timestamp"]) async def get_participants(self) -> list[User | Object]: """A list of :class:`User` that participated in this call. @@ -705,7 +705,7 @@ async def get_participants(self) -> list[User | Object]: return [await self._state.get_user(int(i)) or Object(i) for i in self._participants] @property - def ended_at(self) -> datetime.datetime | None: + def ended_at(self) -> DiscordTime | None: """An aware timestamp of when the call ended.""" return self._ended_timestamp @@ -769,15 +769,15 @@ def __init__( self.flags: MessageFlags = MessageFlags._from_value(data.get("flags", 0)) self.stickers: list[StickerItem] = [StickerItem(data=d, state=state) for d in data.get("sticker_items", [])] self.components: list[Component] = [_component_factory(d) for d in data.get("components", [])] - self._edited_timestamp: datetime.datetime | None = parse_time(data["edited_timestamp"]) + self._edited_timestamp: DiscordTime | None = DiscordTime.parse_time(data["edited_timestamp"]) @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """The original message's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property - def edited_at(self) -> datetime.datetime | None: + def edited_at(self) -> DiscordTime | None: """An aware UTC datetime object containing the edited time of the original message. """ @@ -827,7 +827,7 @@ def __init__( data: MessagePinPayload, ): self._state: ConnectionState = state - self._pinned_at: datetime.datetime = utils.parse_time(data["pinned_at"]) + self._pinned_at: DiscordTime = DiscordTime.parse_time(data["pinned_at"]) self._message: Message = state.create_message(channel=channel, data=data["message"]) @property @@ -836,7 +836,7 @@ def message(self) -> Message: return self._message @property - def pinned_at(self) -> datetime.datetime: + def pinned_at(self) -> DiscordTime: """An aware timestamp of when the message was pinned.""" return self._pinned_at @@ -1054,7 +1054,7 @@ async def _from_data( self.application = data.get("application") self.activity = data.get("activity") self.channel = channel - self._edited_timestamp = parse_time(data["edited_timestamp"]) + self._edited_timestamp = DiscordTime.parse_time(data["edited_timestamp"]) self.type = try_enum(MessageType, data["type"]) self.pinned = data["pinned"] self.flags = MessageFlags._from_value(data.get("flags", 0)) @@ -1332,12 +1332,12 @@ def repl(obj): return escape_mentions(result) @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """The message's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property - def edited_at(self) -> datetime.datetime | None: + def edited_at(self) -> DiscordTime | None: """An aware UTC datetime object containing the edited time of the message. """ @@ -2181,9 +2181,9 @@ def __repr__(self) -> str: return f"" @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """The partial message's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def poll(self) -> Poll | None: diff --git a/discord/monetization.py b/discord/monetization.py index 8f708b47e6..2577e2d3cb 100644 --- a/discord/monetization.py +++ b/discord/monetization.py @@ -27,12 +27,13 @@ from typing import TYPE_CHECKING +from .datetime import DiscordTime from .enums import EntitlementType, SKUType, SubscriptionStatus, try_enum from .flags import SKUFlags from .iterators import SubscriptionIterator from .mixins import Hashable from .utils import MISSING -from .utils.private import get_as_snowflake, parse_time +from .utils.private import get_as_snowflake if TYPE_CHECKING: from datetime import datetime @@ -196,9 +197,9 @@ class Entitlement(Hashable): The type of entitlement. deleted: :class:`bool` Whether the entitlement has been deleted. - starts_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + starts_at: Union[:class:`discord.DiscordTime`, :class:`MISSING`] When the entitlement starts. - ends_at: Union[:class:`datetime.datetime`, :class:`MISSING`] + ends_at: Union[:class:`discord.DiscordTime`, :class:`MISSING`] When the entitlement expires. guild_id: Union[:class:`int`, :class:`MISSING`] The ID of the guild that owns this entitlement. @@ -230,8 +231,10 @@ def __init__(self, *, data: EntitlementPayload, state: ConnectionState) -> None: self.user_id: int | MISSING = get_as_snowflake(data, "user_id") or MISSING self.type: EntitlementType = try_enum(EntitlementType, data["type"]) self.deleted: bool = data["deleted"] - self.starts_at: datetime | MISSING = parse_time(data.get("starts_at")) or MISSING - self.ends_at: datetime | MISSING | None = parse_time(ea) if (ea := data.get("ends_at")) is not None else MISSING + self.starts_at: DiscordTime | MISSING = DiscordTime.parse_time(data.get("starts_at")) or MISSING + self.ends_at: DiscordTime | MISSING | None = ( + DiscordTime.parse_time(ea) if (ea := data.get("ends_at")) is not None else MISSING + ) self.guild_id: int | MISSING = get_as_snowflake(data, "guild_id") or MISSING self.consumed: bool = data.get("consumed", False) @@ -325,10 +328,10 @@ def __init__(self, *, state: ConnectionState, data: SubscriptionPayload) -> None self.sku_ids: list[int] = list(map(int, data["sku_ids"])) self.entitlement_ids: list[int] = list(map(int, data["entitlement_ids"])) self.renewal_sku_ids: list[int] = list(map(int, data["renewal_sku_ids"] or [])) - self.current_period_start: datetime = parse_time(data["current_period_start"]) - self.current_period_end: datetime = parse_time(data["current_period_end"]) + self.current_period_start: DiscordTime = DiscordTime.parse_time(data["current_period_start"]) + self.current_period_end: DiscordTime = DiscordTime.parse_time(data["current_period_end"]) self.status: SubscriptionStatus = try_enum(SubscriptionStatus, data["status"]) - self.canceled_at: datetime | None = parse_time(data.get("canceled_at")) + self.canceled_at: DiscordTime | None = DiscordTime.parse_time(data.get("canceled_at")) self.country: str | None = data.get("country") # Not documented, it is only available with oauth2, not bots def __repr__(self) -> str: diff --git a/discord/object.py b/discord/object.py index 41a8c4706e..0cccf1b6d1 100644 --- a/discord/object.py +++ b/discord/object.py @@ -27,12 +27,10 @@ from typing import TYPE_CHECKING, SupportsInt, Union -from . import utils +from .datetime import DiscordTime from .mixins import Hashable if TYPE_CHECKING: - import datetime - SupportsIntCast = SupportsInt | str | bytes | bytearray __all__ = ("Object",) @@ -84,9 +82,9 @@ def __repr__(self) -> str: return f"" @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the snowflake's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def worker_id(self) -> int: diff --git a/discord/onboarding.py b/discord/onboarding.py index d79fa6b07e..66119dbb23 100644 --- a/discord/onboarding.py +++ b/discord/onboarding.py @@ -27,12 +27,11 @@ from functools import cached_property from typing import TYPE_CHECKING, Any -from discord import utils - from . import utils +from .datetime import DiscordTime from .enums import OnboardingMode, PromptType, try_enum from .partial_emoji import PartialEmoji -from .utils import MISSING, find, generate_snowflake +from .utils import MISSING, find if TYPE_CHECKING: from .abc import Snowflake @@ -84,7 +83,7 @@ def __init__( id: int | None = None, ): # ID is required when making edits, but it can be any snowflake that isn't already used by another prompt during edits - self.id: int = int(id) if id else generate_snowflake(mode="realistic") + self.id: int = int(id) if id else DiscordTime.utcnow().generate_snowflake(mode="realistic") self.title: str = title self.channels: list[Snowflake] = channels or [] self.roles: list[Snowflake] = roles or [] @@ -172,7 +171,7 @@ def __init__( id: int | None = None, # Currently optional as users can manually create these ): # ID is required when making edits, but it can be any snowflake that isn't already used by another prompt during edits - self.id: int = int(id) if id else generate_snowflake(mode="realistic") + self.id: int = int(id) if id else DiscordTime.utcnow().generate_snowflake(mode="realistic") self.type: PromptType = type if isinstance(self.type, int): diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 6d6a62187e..5327580493 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -30,14 +30,13 @@ from . import utils from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .errors import InvalidArgument from .utils.private import get_as_snowflake __all__ = ("PartialEmoji",) if TYPE_CHECKING: - from datetime import datetime - from .app.state import ConnectionState from .types.message import PartialEmoji as PartialEmojiPayload @@ -221,7 +220,7 @@ def _as_reaction(self) -> str: return f"{self.name}:{self.id}" @property - def created_at(self) -> datetime | None: + def created_at(self) -> DiscordTime | None: """Returns the emoji's creation time in UTC, or None if Unicode emoji. .. versionadded:: 1.6 @@ -229,7 +228,7 @@ def created_at(self) -> datetime | None: if self.id is None: return None - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def url(self) -> str: diff --git a/discord/poll.py b/discord/poll.py index 278baaf047..5dec86b808 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -24,15 +24,14 @@ from __future__ import annotations -import datetime from functools import cached_property from typing import TYPE_CHECKING, Any from . import utils +from .datetime import DiscordTime from .enums import PollLayoutType, try_enum from .iterators import VoteIterator from .partial_emoji import PartialEmoji -from .utils.private import parse_time __all__ = ( "PollMedia", @@ -344,9 +343,9 @@ def __init__( self._message = None @cached_property - def expiry(self) -> datetime.datetime | None: + def expiry(self) -> DiscordTime | None: """An aware datetime object that specifies the date and time in UTC when the poll will end.""" - return parse_time(self._expiry) + return DiscordTime.parse_time(self._expiry) def to_dict(self) -> PollPayload: dict_ = { diff --git a/discord/role.py b/discord/role.py index 7944a96d75..4cb72b7455 100644 --- a/discord/role.py +++ b/discord/role.py @@ -32,18 +32,17 @@ from .asset import Asset from .colour import Colour +from .datetime import DiscordTime from .errors import InvalidArgument from .flags import RoleFlags from .mixins import Hashable from .permissions import Permissions -from .utils import MISSING, snowflake_time +from .utils import MISSING from .utils.private import bytes_to_base64_data, deprecated, get_as_snowflake, warn_deprecated __all__ = ("RoleTags", "Role", "RoleColours") if TYPE_CHECKING: - import datetime - from .app.state import ConnectionState from .guild import Guild from .member import Member @@ -481,9 +480,9 @@ def colors(self) -> RoleColours: return self.colours @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the role's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def mention(self) -> str: diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index 0f657fca84..1abb928d14 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -29,6 +29,7 @@ from . import utils from .asset import Asset +from .datetime import DiscordTime from .enums import ( ScheduledEventLocationType, ScheduledEventPrivacyLevel, @@ -146,9 +147,9 @@ class ScheduledEvent(Hashable): The name of the scheduled event. description: Optional[:class:`str`] The description of the scheduled event. - start_time: :class:`datetime.datetime` + start_time: :class:`discord.DiscordTime` The time when the event will start - end_time: Optional[:class:`datetime.datetime`] + end_time: Optional[:class:`discord.DiscordTime`] The time when the event is supposed to end. status: :class:`ScheduledEventStatus` The status of the scheduled event. @@ -200,10 +201,8 @@ def __init__( self.name: str = data.get("name") self.description: str | None = data.get("description", None) self._image: str | None = data.get("image", None) - self.start_time: datetime.datetime = datetime.datetime.fromisoformat(data.get("scheduled_start_time")) - if end_time := data.get("scheduled_end_time", None): - end_time = datetime.datetime.fromisoformat(end_time) - self.end_time: datetime.datetime | None = end_time + self.start_time: DiscordTime = DiscordTime.fromisoformat(data.get("scheduled_start_time")) + self.end_time: DiscordTime | None = DiscordTime.parse_time(data.get("scheduled_end_time")) self.status: ScheduledEventStatus = try_enum(ScheduledEventStatus, data.get("status")) self.subscriber_count: int | None = data.get("user_count", None) self.creator_id: int | None = get_as_snowflake(data, "creator_id") @@ -233,9 +232,9 @@ def __repr__(self) -> str: ) @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the scheduled event's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def interested(self) -> int | None: diff --git a/discord/sticker.py b/discord/sticker.py index a73b19bd0a..12a06e1dd7 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -29,10 +29,11 @@ from typing import TYPE_CHECKING, Literal from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .enums import StickerFormatType, StickerType, try_enum from .errors import InvalidData from .mixins import Hashable -from .utils import MISSING, Undefined, find, snowflake_time +from .utils import MISSING, Undefined, find from .utils.private import cached_slot_property __all__ = ( @@ -44,8 +45,6 @@ ) if TYPE_CHECKING: - import datetime - from .app.state import ConnectionState from .guild import Guild from .types.sticker import EditGuildSticker @@ -293,9 +292,9 @@ def __str__(self) -> str: return self.name @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the sticker's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) class StandardSticker(Sticker): diff --git a/discord/template.py b/discord/template.py index 970f1eb435..8817a68c74 100644 --- a/discord/template.py +++ b/discord/template.py @@ -29,15 +29,14 @@ from typing_extensions import Self +from .datetime import DiscordTime from .guild import Guild from .utils import MISSING, Undefined -from .utils.private import bytes_to_base64_data, parse_time +from .utils.private import bytes_to_base64_data __all__ = ("Template",) if TYPE_CHECKING: - import datetime - from .app.state import ConnectionState from .types.template import Template as TemplatePayload from .user import User @@ -107,9 +106,9 @@ class Template: The description of the template. creator: :class:`User` The creator of the template. - created_at: :class:`datetime.datetime` + created_at: :class:`discord.DiscordTime` An aware datetime in UTC representing when the template was created. - updated_at: :class:`datetime.datetime` + updated_at: :class:`discord.DiscordTime` An aware datetime in UTC representing when the template was last updated. This is referred to as "last synced" in the official Discord client. source_guild: :class:`Guild` @@ -145,8 +144,8 @@ async def from_data(cls, state: ConnectionState, data: TemplatePayload) -> Self: creator_data = data.get("creator") self.creator: User | None = None if creator_data is None else self._state.create_user(creator_data) - self.created_at: datetime.datetime | None = parse_time(data.get("created_at")) - self.updated_at: datetime.datetime | None = parse_time(data.get("updated_at")) + self.created_at: DiscordTime | None = DiscordTime.parse_time(data.get("created_at")) + self.updated_at: DiscordTime | None = DiscordTime.parse_time(data.get("updated_at")) guild_id = int(data["source_guild_id"]) guild: Guild | None = await self._state._get_guild(guild_id) diff --git a/discord/user.py b/discord/user.py index 82d9353d01..6b23fd817b 100644 --- a/discord/user.py +++ b/discord/user.py @@ -33,16 +33,15 @@ from .asset import Asset from .collectibles import Nameplate from .colour import Colour +from .datetime import DiscordTime from .flags import PublicUserFlags from .iterators import EntitlementIterator from .monetization import Entitlement from .primary_guild import PrimaryGuild -from .utils import MISSING, Undefined, snowflake_time +from .utils import MISSING, Undefined from .utils.private import bytes_to_base64_data if TYPE_CHECKING: - from datetime import datetime - from .abc import Snowflake, SnowflakeTime from .app.state import ConnectionState from .channel import DMChannel @@ -304,12 +303,12 @@ def mention(self) -> str: return f"<@{self.id}>" @property - def created_at(self) -> datetime: + def created_at(self) -> DiscordTime: """Returns the user's creation time in UTC. This is when the user's Discord account was created. """ - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def display_name(self) -> str: diff --git a/discord/utils/__init__.py b/discord/utils/__init__.py index e136a27cdc..0e9db76211 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -34,30 +34,22 @@ escape_markdown, escape_mentions, find, - format_dt, - generate_snowflake, oauth_url, raw_channel_mentions, raw_mentions, raw_role_mentions, remove_markdown, - snowflake_time, - utcnow, ) __all__ = ( "oauth_url", - "snowflake_time", "find", - "utcnow", "remove_markdown", "escape_markdown", "escape_mentions", "raw_mentions", "raw_channel_mentions", "raw_role_mentions", - "format_dt", - "generate_snowflake", "basic_autocomplete", "Undefined", "MISSING", diff --git a/discord/utils/private.py b/discord/utils/private.py index 48790ce833..9aab08061f 100644 --- a/discord/utils/private.py +++ b/discord/utils/private.py @@ -160,32 +160,6 @@ def resolve_template(code: Template | str) -> str: ) -@overload -def parse_time(timestamp: None) -> None: ... - - -@overload -def parse_time(timestamp: str) -> datetime.datetime: ... - - -def parse_time(timestamp: str | None) -> datetime.datetime | None: - """A helper function to convert an ISO 8601 timestamp to a datetime object. - - Parameters - ---------- - timestamp: Optional[:class:`str`] - The timestamp to convert. - - Returns - ------- - Optional[:class:`datetime.datetime`] - The converted datetime object. - """ - if timestamp: - return datetime.datetime.fromisoformat(timestamp) - return None - - def warn_deprecated( name: str, instead: str | None = None, diff --git a/discord/utils/public.py b/discord/utils/public.py index 3778f1360f..eeaaf1fd72 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import datetime import importlib.resources import itertools import json @@ -33,23 +32,6 @@ def __bool__(self) -> Literal[False]: DISCORD_EPOCH = 1420070400000 - -def utcnow() -> datetime.datetime: - """A helper function to return an aware UTC datetime representing the current time. - - This should be preferred to :meth:`datetime.datetime.utcnow` since it is an aware - datetime, compared to the naive datetime in the standard library. - - .. versionadded:: 2.0 - - Returns - ------- - :class:`datetime.datetime` - The current aware datetime in UTC. - """ - return datetime.datetime.now(datetime.timezone.utc) - - V = Iterable["OptionChoice"] | Iterable[str] | Iterable[int] | Iterable[float] AV = Awaitable[V] Values = V | Callable[["AutocompleteContext"], V | AV] | AV @@ -150,77 +132,6 @@ def _filter(ctx: AutocompleteContext, item: OptionChoice | str | int | float) -> return autocomplete_callback -def generate_snowflake( - dt: datetime.datetime | None = None, - *, - mode: Literal["boundary", "realistic"] = "boundary", - high: bool = False, -) -> int: - """Returns a numeric snowflake pretending to be created at the given date. - - This function can generate both realistic snowflakes (for general use) and - boundary snowflakes (for range queries). - - Parameters - ---------- - dt: :class:`datetime.datetime` - A datetime object to convert to a snowflake. - If naive, the timezone is assumed to be local time. - If None, uses current UTC time. - mode: :class:`str` - The type of snowflake to generate: - - "realistic": Creates a snowflake with random-like lower bits - - "boundary": Creates a snowflake for range queries (default) - high: :class:`bool` - Only used when mode="boundary". Whether to set the lower 22 bits - to high (True) or low (False). Default is False. - - Returns - ------- - :class:`int` - The snowflake representing the time given. - - Examples - -------- - # Generate realistic snowflake - snowflake = generate_snowflake(dt) - - # Generate boundary snowflakes - lower_bound = generate_snowflake(dt, mode="boundary", high=False) - upper_bound = generate_snowflake(dt, mode="boundary", high=True) - - # For inclusive ranges: - # Lower: generate_snowflake(dt, mode="boundary", high=False) - 1 - # Upper: generate_snowflake(dt, mode="boundary", high=True) + 1 - """ - dt = dt or utcnow() - discord_millis = int(dt.timestamp() * 1000 - DISCORD_EPOCH) - - if mode == "realistic": - return (discord_millis << 22) | 0x3FFFFF - elif mode == "boundary": - return (discord_millis << 22) + (2**22 - 1 if high else 0) - else: - raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") - - -def snowflake_time(id: int) -> datetime.datetime: - """Converts a Discord snowflake ID to a UTC-aware datetime object. - - Parameters - ---------- - id: :class:`int` - The snowflake ID. - - Returns - ------- - :class:`datetime.datetime` - An aware datetime in UTC representing the creation time of the snowflake. - """ - timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 - return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) - - def oauth_url( client_id: int | str, *, @@ -273,56 +184,6 @@ def oauth_url( return url -TimestampStyle = Literal["f", "F", "d", "D", "t", "T", "R"] - - -def format_dt(dt: datetime.datetime | datetime.time, /, style: TimestampStyle | None = None) -> str: - """A helper function to format a :class:`datetime.datetime` for presentation within Discord. - - This allows for a locale-independent way of presenting data using Discord specific Markdown. - - +-------------+----------------------------+-----------------+ - | Style | Example Output | Description | - +=============+============================+=================+ - | t | 22:57 | Short Time | - +-------------+----------------------------+-----------------+ - | T | 22:57:58 | Long Time | - +-------------+----------------------------+-----------------+ - | d | 17/05/2016 | Short Date | - +-------------+----------------------------+-----------------+ - | D | 17 May 2016 | Long Date | - +-------------+----------------------------+-----------------+ - | f (default) | 17 May 2016 22:57 | Short Date Time | - +-------------+----------------------------+-----------------+ - | F | Tuesday, 17 May 2016 22:57 | Long Date Time | - +-------------+----------------------------+-----------------+ - | R | 5 years ago | Relative Time | - +-------------+----------------------------+-----------------+ - - Note that the exact output depends on the user's locale setting in the client. The example output - presented is using the ``en-GB`` locale. - - .. versionadded:: 2.0 - - Parameters - ---------- - dt: Union[:class:`datetime.datetime`, :class:`datetime.time`] - The datetime to format. - style: :class:`str`R - The style to format the datetime with. - - Returns - ------- - :class:`str` - The formatted string. - """ - if isinstance(dt, datetime.time): - dt = datetime.datetime.combine(datetime.datetime.now(), dt) - if style is None: - return f"" - return f"" - - MENTION_PATTERN = re.compile(r"@(everyone|here|[!&]?[0-9]{17,20})") diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 37985e5da9..c01f7fa0f2 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -40,6 +40,7 @@ from ..asset import Asset from ..channel import ForumChannel, PartialMessageable from ..channel.thread import Thread +from ..datetime import DiscordTime from ..enums import WebhookType, try_enum from ..errors import ( DiscordServerError, @@ -67,8 +68,6 @@ _log = logging.getLogger(__name__) if TYPE_CHECKING: - import datetime - from ..abc import Snowflake from ..app.state import ConnectionState from ..channel import TextChannel @@ -1086,9 +1085,9 @@ def channel(self) -> TextChannel | None: return guild and guild.get_channel(self.channel_id) # type: ignore @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the webhook's creation time in UTC.""" - return utils.snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def avatar(self) -> Asset: diff --git a/discord/widget.py b/discord/widget.py index f9d043275c..1b86aa5296 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -28,15 +28,13 @@ from typing import TYPE_CHECKING, Any from .activity import BaseActivity, Spotify, create_activity +from .datetime import DiscordTime from .enums import Status, try_enum from .invite import Invite from .user import BaseUser -from .utils import snowflake_time from .utils.private import get_as_snowflake, resolve_invite if TYPE_CHECKING: - import datetime - from .app.state import ConnectionState from .types.widget import Widget as WidgetPayload from .types.widget import WidgetMember as WidgetMemberPayload @@ -98,9 +96,9 @@ def mention(self) -> str: return f"<#{self.id}>" @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the channel's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) class WidgetMember(BaseUser): @@ -286,9 +284,9 @@ def __repr__(self) -> str: return f"" @property - def created_at(self) -> datetime.datetime: + def created_at(self) -> DiscordTime: """Returns the member's creation time in UTC.""" - return snowflake_time(self.id) + return DiscordTime.from_snowflake(self.id) @property def json_url(self) -> str: diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index ad71d7deee..57812a0296 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -247,3 +247,9 @@ Application Role Connections .. autoclass:: ApplicationRoleConnectionMetadata :members: + +Datetime +-------- + +.. autoclass:: discord.DiscordTime + :members: \ No newline at end of file diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 2ee4104c27..0b433f3855 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -21,12 +21,4 @@ Utility Functions .. autofunction:: discord.utils.raw_role_mentions -.. autofunction:: discord.utils.utcnow - -.. autofunction:: discord.utils.snowflake_time - -.. autofunction:: discord.utils.format_dt - -.. autofunction:: discord.utils.generate_snowflake - .. autofunction:: discord.utils.basic_autocomplete diff --git a/examples/app_commands/info.py b/examples/app_commands/info.py index 7040279d07..93a427517f 100644 --- a/examples/app_commands/info.py +++ b/examples/app_commands/info.py @@ -20,7 +20,7 @@ async def info(ctx: discord.ApplicationContext, user: discord.Member = None): discord.EmbedField(name="ID", value=str(user.id), inline=False), # User ID discord.EmbedField( name="Created", - value=discord.utils.format_dt(user.created_at, "F"), + value=user.created_at.format("F"), inline=False, ), # When the user's account was created ], @@ -36,7 +36,7 @@ async def info(ctx: discord.ApplicationContext, user: discord.Member = None): else: # We end up here if the user is a discord.Member object embed.add_field( name="Joined", - value=discord.utils.format_dt(user.joined_at, "F"), + value=user.joined_at.format("F"), inline=False, ) # When the user joined the server diff --git a/examples/app_commands/slash_basic.py b/examples/app_commands/slash_basic.py index dea1014456..2d57341d19 100644 --- a/examples/app_commands/slash_basic.py +++ b/examples/app_commands/slash_basic.py @@ -35,7 +35,7 @@ async def global_command(ctx: discord.ApplicationContext, num: int): # Takes on async def joined(ctx: discord.ApplicationContext, member: discord.Member = None): # Setting a default value for the member parameter makes it optional ^ user = member or ctx.author - await ctx.respond(f"{user.name} joined at {discord.utils.format_dt(user.joined_at)}") + await ctx.respond(f"{user.name} joined at {user.joined_at.format()}") # To learn how to add descriptions and choices to options, check slash_options.py diff --git a/examples/timeout.py b/examples/timeout.py index 25ab34292c..a2c42c6254 100644 --- a/examples/timeout.py +++ b/examples/timeout.py @@ -16,7 +16,7 @@ async def timeout(ctx: discord.ApplicationContext, member: discord.Member, minut """ The method used above is a shortcut for: - until = discord.utils.utcnow() + datetime.timedelta(minutes=minutes) + until = discord.DiscordTime.utcnow() + datetime.timedelta(minutes=minutes) await member.timeout(until) """ diff --git a/tests/test_format_dt.py b/tests/test_format_dt.py index fd673e428d..ae1b406b0a 100644 --- a/tests/test_format_dt.py +++ b/tests/test_format_dt.py @@ -27,7 +27,7 @@ import pytest -from discord.utils.public import TimestampStyle, format_dt +from discord.datetime import DiscordTime, TimestampStyle # Fix seed so that time tests are reproducible random.seed(42) @@ -71,7 +71,7 @@ def test_format_dt_formats_datetime( expected = f"" else: expected = f"" - result = format_dt(dt, style=style) + result = DiscordTime.from_datetime(dt).format(style=style) assert result == expected @@ -81,7 +81,7 @@ def test_format_dt_formats_time_equivalence( ) -> None: tm = random_time() today = datetime.datetime.now().date() - result_time = format_dt(tm, style=style) + result_time = DiscordTime.from_datetime(tm).format(style=style) dt = datetime.datetime.combine(today, tm) - result_dt = format_dt(dt, style=style) + result_dt = DiscordTime.from_datetime(dt).format(style=style) assert result_time == result_dt diff --git a/tests/test_snowflake_datetime.py b/tests/test_snowflake_datetime.py index bb73010a57..f76b4c7c54 100644 --- a/tests/test_snowflake_datetime.py +++ b/tests/test_snowflake_datetime.py @@ -26,11 +26,8 @@ import pytest -from discord.utils import ( - DISCORD_EPOCH, - generate_snowflake, - snowflake_time, -) +from discord import DiscordTime +from discord.datetime import DISCORD_EPOCH UTC = datetime.timezone.utc @@ -45,39 +42,39 @@ @pytest.mark.parametrize(("dt", "expected_ms"), DATETIME_CASES) def test_generate_snowflake_realistic(dt: datetime.datetime, expected_ms: int) -> None: - sf = generate_snowflake(dt, mode="realistic") + sf = DiscordTime.from_datetime(dt).generate_snowflake(mode="realistic") assert (sf >> 22) == expected_ms assert (sf & ((1 << 22) - 1)) == 0x3FFFFF @pytest.mark.parametrize(("dt", "expected_ms"), DATETIME_CASES) def test_generate_snowflake_boundary_low(dt: datetime.datetime, expected_ms: int) -> None: - sf = generate_snowflake(dt, mode="boundary", high=False) + sf = DiscordTime.from_datetime(dt).generate_snowflake(mode="boundary", high=False) assert (sf >> 22) == expected_ms assert (sf & ((1 << 22) - 1)) == 0 @pytest.mark.parametrize(("dt", "expected_ms"), DATETIME_CASES) def test_generate_snowflake_boundary_high(dt: datetime.datetime, expected_ms: int) -> None: - sf = generate_snowflake(dt, mode="boundary", high=True) + sf = DiscordTime.from_datetime(dt).generate_snowflake(mode="boundary", high=True) assert (sf >> 22) == expected_ms assert (sf & ((1 << 22) - 1)) == (2**22 - 1) @pytest.mark.parametrize(("dt", "_expected_ms"), DATETIME_CASES) def test_snowflake_time_roundtrip_boundary(dt: datetime.datetime, _expected_ms: int) -> None: - sf_low = generate_snowflake(dt, mode="boundary", high=False) - sf_high = generate_snowflake(dt, mode="boundary", high=True) - assert snowflake_time(sf_low) == dt - assert snowflake_time(sf_high) == dt + sf_low = DiscordTime.from_datetime(dt).generate_snowflake(mode="boundary", high=False) + sf_high = DiscordTime.from_datetime(dt).generate_snowflake(mode="boundary", high=True) + assert DiscordTime.from_snowflake(sf_low) == dt + assert DiscordTime.from_snowflake(sf_high) == dt @pytest.mark.parametrize(("dt", "_expected_ms"), DATETIME_CASES) def test_snowflake_time_roundtrip_realistic(dt: datetime.datetime, _expected_ms: int) -> None: - sf = generate_snowflake(dt, mode="realistic") - assert snowflake_time(sf) == dt + sf = DiscordTime.from_datetime(dt).generate_snowflake(mode="realistic") + assert DiscordTime.from_snowflake(sf) == dt def test_generate_snowflake_invalid_mode() -> None: with pytest.raises(ValueError, match=r"Invalid mode 'nope'. Must be 'realistic' or 'boundary'"): - generate_snowflake(datetime.datetime.now(tz=UTC), mode="nope") # ty: ignore[invalid-argument-type] + DiscordTime.from_datetime(datetime.datetime.now(tz=UTC)).generate_snowflake(mode="nope") # ty: ignore[invalid-argument-type]