From 2318dfd782fc79f949fc01a90d88f902b194c122 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 14:33:51 +0100 Subject: [PATCH 01/26] First overall draft to PR and for feedback --- discord/__init__.py | 1 + discord/commands/core.py | 5 ++-- discord/datetime.py | 52 +++++++++++++++++++++++++++++++++++++++ discord/utils/__init__.py | 2 -- discord/utils/public.py | 27 +++++--------------- 5 files changed, 62 insertions(+), 25 deletions(-) create mode 100644 discord/datetime.py 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/commands/core.py b/discord/commands/core.py index acbc2a757f..708dc5af33 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -46,6 +46,7 @@ ) from discord.interactions import AutocompleteInteraction, Interaction +from discord.datetime import DiscordTime from ..channel import PartialMessageable, _threaded_guild_channel_factory from ..channel.thread import Thread @@ -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: diff --git a/discord/datetime.py b/discord/datetime.py new file mode 100644 index 0000000000..7545d81fc0 --- /dev/null +++ b/discord/datetime.py @@ -0,0 +1,52 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-2021 Rapptz +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. +""" + +import datetime + +import typing_extensions + +__all__ = ( + "DiscordTime", +) + + +class DiscordTime(datetime.datetime): + """A subclass of `datetime.datetime` that offers additional utility methods + .. versionadded:: 3.0 + """ + + @classmethod + def utcnow(cls) -> typing_extensions.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) diff --git a/discord/utils/__init__.py b/discord/utils/__init__.py index e136a27cdc..ab51e4d60a 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -42,14 +42,12 @@ raw_role_mentions, remove_markdown, snowflake_time, - utcnow, ) __all__ = ( "oauth_url", "snowflake_time", "find", - "utcnow", "remove_markdown", "escape_markdown", "escape_mentions", diff --git a/discord/utils/public.py b/discord/utils/public.py index 3778f1360f..bf36504e8c 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -11,6 +11,8 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast +from discord.datetime import DiscordTime + if TYPE_CHECKING: from ..abc import Snowflake from ..commands.context import AutocompleteContext @@ -33,23 +35,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 @@ -151,7 +136,7 @@ def _filter(ctx: AutocompleteContext, item: OptionChoice | str | int | float) -> def generate_snowflake( - dt: datetime.datetime | None = None, + dt: DiscordTime | None = None, *, mode: Literal["boundary", "realistic"] = "boundary", high: bool = False, @@ -193,7 +178,7 @@ def generate_snowflake( # Lower: generate_snowflake(dt, mode="boundary", high=False) - 1 # Upper: generate_snowflake(dt, mode="boundary", high=True) + 1 """ - dt = dt or utcnow() + dt = dt or DiscordTime.utcnow() discord_millis = int(dt.timestamp() * 1000 - DISCORD_EPOCH) if mode == "realistic": @@ -204,7 +189,7 @@ def generate_snowflake( raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") -def snowflake_time(id: int) -> datetime.datetime: +def snowflake_time(id: int) -> DiscordTime: """Converts a Discord snowflake ID to a UTC-aware datetime object. Parameters @@ -218,7 +203,7 @@ def snowflake_time(id: int) -> 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) + return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) def oauth_url( From 32cebf27aeae2dceeb94c751854cbecb800ba38e Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 14:47:41 +0100 Subject: [PATCH 02/26] Apply change request --- discord/commands/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index 708dc5af33..8630be5b88 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -46,10 +46,10 @@ ) from discord.interactions import AutocompleteInteraction, Interaction -from discord.datetime import DiscordTime 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, From cf54a23c81cfea87ba2eea656ebc2f723190dc10 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 15:14:49 +0100 Subject: [PATCH 03/26] Move generate_snowflake method over --- discord/commands/core.py | 2 +- discord/datetime.py | 60 +++++++++++++++++++++++++++++++- discord/iterators.py | 33 +++++++++--------- discord/onboarding.py | 8 ++--- discord/utils/__init__.py | 2 -- discord/utils/public.py | 55 ----------------------------- docs/api/utils.rst | 4 --- examples/timeout.py | 2 +- tests/test_snowflake_datetime.py | 17 ++++----- 9 files changed, 91 insertions(+), 92 deletions(-) diff --git a/discord/commands/core.py b/discord/commands/core.py index 8630be5b88..2ef81ee74f 100644 --- a/discord/commands/core.py +++ b/discord/commands/core.py @@ -412,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 index 7545d81fc0..fa399919fb 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -24,6 +24,7 @@ """ import datetime +from typing import override, Literal import typing_extensions @@ -31,12 +32,16 @@ "DiscordTime", ) +DISCORD_EPOCH = 1420070400000 + class DiscordTime(datetime.datetime): """A subclass of `datetime.datetime` that offers additional utility methods + .. versionadded:: 3.0 """ + @override @classmethod def utcnow(cls) -> typing_extensions.Self: """A helper function to return an aware UTC datetime representing the current time. @@ -49,4 +54,57 @@ def utcnow(cls) -> typing_extensions.Self: :class:`discord.DiscordTime` The current aware datetime in UTC. """ - return cls.now(datetime.timezone.utc) + return cls.now(datetime.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 + -------- + # 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) -> typing_extensions.Self: + 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) diff --git a/discord/iterators.py b/discord/iterators.py index 994469ab35..73c81f305c 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -40,9 +40,10 @@ ) 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 import snowflake_time from .utils.private import maybe_awaitable, warn_deprecated __all__ = ( @@ -331,11 +332,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 +458,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 +569,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 +656,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,7 +788,7 @@ 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: @@ -863,9 +864,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 +958,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 +1072,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 diff --git a/discord/onboarding.py b/discord/onboarding.py index d79fa6b07e..84f54fe359 100644 --- a/discord/onboarding.py +++ b/discord/onboarding.py @@ -27,12 +27,12 @@ from functools import cached_property from typing import TYPE_CHECKING, Any -from discord import utils +from discord import utils, DiscordTime from . import utils 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 +84,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 +172,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/utils/__init__.py b/discord/utils/__init__.py index ab51e4d60a..4ac558592f 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -35,7 +35,6 @@ escape_mentions, find, format_dt, - generate_snowflake, oauth_url, raw_channel_mentions, raw_mentions, @@ -55,7 +54,6 @@ "raw_channel_mentions", "raw_role_mentions", "format_dt", - "generate_snowflake", "basic_autocomplete", "Undefined", "MISSING", diff --git a/discord/utils/public.py b/discord/utils/public.py index bf36504e8c..4e970699a3 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -134,61 +134,6 @@ def _filter(ctx: AutocompleteContext, item: OptionChoice | str | int | float) -> return autocomplete_callback - -def generate_snowflake( - dt: DiscordTime | 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 DiscordTime.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) -> DiscordTime: """Converts a Discord snowflake ID to a UTC-aware datetime object. diff --git a/docs/api/utils.rst b/docs/api/utils.rst index 2ee4104c27..ed79218a26 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -21,12 +21,8 @@ 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/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_snowflake_datetime.py b/tests/test_snowflake_datetime.py index bb73010a57..85d1ce6975 100644 --- a/tests/test_snowflake_datetime.py +++ b/tests/test_snowflake_datetime.py @@ -26,9 +26,9 @@ import pytest +from discord import DiscordTime from discord.utils import ( DISCORD_EPOCH, - generate_snowflake, snowflake_time, ) @@ -45,39 +45,40 @@ @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) + 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 snowflake_time(sf_low) == dt assert snowflake_time(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") + sf = DiscordTime.from_datetime(dt).generate_snowflake(mode="realistic") assert snowflake_time(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] From 93c16ddb3a2b7d61cffd55ccf1b2f8ca5e7cd95f Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 15:17:06 +0100 Subject: [PATCH 04/26] Adjust changelog --- CHANGELOG-NEXT.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index ab61bed6a2..c1b26baad1 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -5,6 +5,9 @@ release. ### Added +- `discord.datetime.Datetime` class, a `datetime.datetime` subclass that offers additional + functionality for snowflakes and util methods. + ### Fixed ### Changed @@ -28,3 +31,5 @@ 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.Datetime` +- `utils.utcnow`, moved to `discord.datetime.Datetime` From 57c2304b9e7cedab4fb4b2a26f56219feec47b26 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 15:19:53 +0100 Subject: [PATCH 05/26] Apply change requests by @NeloBlivion --- discord/datetime.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/discord/datetime.py b/discord/datetime.py index fa399919fb..8688a327bf 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -1,7 +1,6 @@ """ The MIT License (MIT) -Copyright (c) 2015-2021 Rapptz Copyright (c) 2021-present Pycord Development Permission is hereby granted, free of charge, to any person obtaining a @@ -26,7 +25,7 @@ import datetime from typing import override, Literal -import typing_extensions +from typing_extensions import Self __all__ = ( "DiscordTime", @@ -43,7 +42,7 @@ class DiscordTime(datetime.datetime): @override @classmethod - def utcnow(cls) -> typing_extensions.Self: + 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 @@ -105,6 +104,6 @@ def generate_snowflake( raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") @classmethod - def from_datetime(cls, dt: datetime.datetime) -> typing_extensions.Self: + def from_datetime(cls, dt: datetime.datetime) -> Self: 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) From 96fc400c7de68f6c1ad9b003e74af944b72e2e5d Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 15:51:46 +0100 Subject: [PATCH 06/26] Move snowflake_time (now from_snowflake) to DiscordTime --- CHANGELOG-NEXT.md | 1 + discord/audit_logs.py | 4 ++-- discord/channel/base.py | 7 ++++--- discord/datetime.py | 17 +++++++++++++++++ discord/emoji.py | 5 +++-- discord/guild.py | 7 +++---- discord/interactions.py | 6 +++--- discord/invite.py | 8 ++++---- discord/iterators.py | 5 ++--- discord/message.py | 20 ++++++++++---------- discord/object.py | 8 +++----- discord/partial_emoji.py | 8 +++----- discord/role.py | 7 ++++--- discord/scheduled_events.py | 6 +++--- discord/sticker.py | 9 ++++----- discord/user.py | 9 ++++----- discord/utils/__init__.py | 2 -- discord/utils/public.py | 19 +------------------ discord/webhook/async_.py | 8 +++----- discord/widget.py | 12 +++++------- docs/api/utils.rst | 2 -- tests/test_snowflake_datetime.py | 11 ++++------- 22 files changed, 83 insertions(+), 98 deletions(-) diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index c1b26baad1..131811fa6d 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -33,3 +33,4 @@ release. instead - `utils.generate_snowflake`, moved to `discord.datetime.Datetime` - `utils.utcnow`, moved to `discord.datetime.Datetime` +- `utils.snowflake_time`, moved to `discord.datetime.Datetime` as `Datetime.from_snowflake` diff --git a/discord/audit_logs.py b/discord/audit_logs.py index a59efed1e9..b91467e4b0 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -30,7 +30,7 @@ from inspect import isawaitable from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, TypeVar -from . import enums, utils +from . import enums, DiscordTime from .asset import Asset from .automod import AutoModAction, AutoModTriggerMetadata from .colour import Colour @@ -587,7 +587,7 @@ def __repr__(self) -> str: @cached_property def created_at(self) -> datetime.datetime: """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..f18821b2ec 100644 --- a/discord/channel/base.py +++ b/discord/channel/base.py @@ -33,6 +33,7 @@ from typing_extensions import Self, TypeVar, override +from .. import DiscordTime from ..abc import Messageable, Snowflake, SnowflakeTime, User, _Overwrites, _purge_messages_helper from ..emoji import GuildEmoji, PartialEmoji from ..enums import ChannelType, InviteTarget, SortOrder, try_enum @@ -40,7 +41,7 @@ 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/datetime.py b/discord/datetime.py index 8688a327bf..5545ca0a48 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -107,3 +107,20 @@ def generate_snowflake( def from_datetime(cls, dt: datetime.datetime) -> Self: 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.datetime.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) diff --git a/discord/emoji.py b/discord/emoji.py index bfe44e8d4d..ffdf23aa27 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -27,10 +27,11 @@ from typing import TYPE_CHECKING, Any, Iterator +from . import DiscordTime from .asset import Asset, AssetMixin 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__ = ( @@ -102,7 +103,7 @@ def __hash__(self) -> int: @property def created_at(self) -> datetime: """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/guild.py b/discord/guild.py index 413dffc20b..14ca1fda20 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, @@ -46,7 +45,7 @@ from typing_extensions import Self, override -from . import abc, utils +from . import abc, utils, DiscordTime from .asset import Asset from .automod import AutoModAction, AutoModRule, AutoModTriggerMetadata from .channel import * @@ -1259,9 +1258,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/interactions.py b/discord/interactions.py index 6bd161a637..59eeaa9068 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -31,7 +31,7 @@ from typing_extensions import Self, TypeVar, override, reveal_type -from . import utils +from . import utils, DiscordTime from .channel import ChannelType, PartialMessageable, _threaded_channel_factory from .enums import ( InteractionContextType, @@ -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..4ad408db14 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -27,12 +27,12 @@ from typing import TYPE_CHECKING, TypeVar, Union +from . import DiscordTime from .appinfo import PartialAppInfo from .asset import Asset 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 __all__ = ( @@ -113,9 +113,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: @@ -191,7 +191,7 @@ def __repr__(self) -> str: @property def created_at(self) -> datetime.datetime: """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: diff --git a/discord/iterators.py b/discord/iterators.py index 73c81f305c..a46bd5aa63 100644 --- a/discord/iterators.py +++ b/discord/iterators.py @@ -43,7 +43,6 @@ from .datetime import DiscordTime from .errors import NoMoreItems from .object import Object -from .utils import snowflake_time from .utils.private import maybe_awaitable, warn_deprecated __all__ = ( @@ -795,7 +794,7 @@ def __init__( 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 @@ -1180,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/message.py b/discord/message.py index caae31278f..543a5fd33b 100644 --- a/discord/message.py +++ b/discord/message.py @@ -45,7 +45,7 @@ from typing_extensions import Self -from . import utils +from . import utils, DiscordTime from .channel import PartialMessageable from .channel.thread import Thread from .components import _component_factory @@ -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 = 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. """ @@ -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/object.py b/discord/object.py index 41a8c4706e..3a2c029601 100644 --- a/discord/object.py +++ b/discord/object.py @@ -27,12 +27,10 @@ from typing import TYPE_CHECKING, SupportsInt, Union -from . import utils +from . 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/partial_emoji.py b/discord/partial_emoji.py index 6d6a62187e..c01cb544fb 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -28,7 +28,7 @@ import re from typing import TYPE_CHECKING, Any, TypedDict, TypeVar -from . import utils +from . import utils, DiscordTime from .asset import Asset, AssetMixin from .errors import InvalidArgument from .utils.private import get_as_snowflake @@ -36,8 +36,6 @@ __all__ = ("PartialEmoji",) if TYPE_CHECKING: - from datetime import datetime - from .app.state import ConnectionState from .types.message import PartialEmoji as PartialEmojiPayload @@ -221,7 +219,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 +227,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/role.py b/discord/role.py index 7944a96d75..cb62792a5e 100644 --- a/discord/role.py +++ b/discord/role.py @@ -30,13 +30,14 @@ from typing_extensions import Self +from . import DiscordTime from .asset import Asset from .colour import Colour 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") @@ -481,9 +482,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..b6ada03fe3 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -27,7 +27,7 @@ import datetime from typing import TYPE_CHECKING, Any -from . import utils +from . import utils, DiscordTime from .asset import Asset from .enums import ( ScheduledEventLocationType, @@ -233,9 +233,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..100103187f 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -28,11 +28,12 @@ import unicodedata from typing import TYPE_CHECKING, Literal +from . import DiscordTime from .asset import Asset, AssetMixin 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/user.py b/discord/user.py index 82d9353d01..6fe58a9a6a 100644 --- a/discord/user.py +++ b/discord/user.py @@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any, TypeVar import discord.abc +from . import DiscordTime from .asset import Asset from .collectibles import Nameplate @@ -37,12 +38,10 @@ 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 4ac558592f..46c7d0d9ed 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -40,12 +40,10 @@ raw_mentions, raw_role_mentions, remove_markdown, - snowflake_time, ) __all__ = ( "oauth_url", - "snowflake_time", "find", "remove_markdown", "escape_markdown", diff --git a/discord/utils/public.py b/discord/utils/public.py index 4e970699a3..c52fe62357 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -11,7 +11,7 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from discord.datetime import DiscordTime +from discord import DiscordTime if TYPE_CHECKING: from ..abc import Snowflake @@ -134,23 +134,6 @@ def _filter(ctx: AutocompleteContext, item: OptionChoice | str | int | float) -> return autocomplete_callback -def snowflake_time(id: int) -> DiscordTime: - """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 DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) - - def oauth_url( client_id: int | str, *, diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 37985e5da9..ca64ea6864 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -36,7 +36,7 @@ import aiohttp -from .. import utils +from .. import utils, DiscordTime from ..asset import Asset from ..channel import ForumChannel, PartialMessageable from ..channel.thread import Thread @@ -67,8 +67,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 +1084,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..72f28ecf20 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -27,16 +27,14 @@ from typing import TYPE_CHECKING, Any +from . import DiscordTime from .activity import BaseActivity, Spotify, create_activity 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/utils.rst b/docs/api/utils.rst index ed79218a26..2e8bbea8ce 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -21,8 +21,6 @@ Utility Functions .. autofunction:: discord.utils.raw_role_mentions -.. autofunction:: discord.utils.snowflake_time - .. autofunction:: discord.utils.format_dt .. autofunction:: discord.utils.basic_autocomplete diff --git a/tests/test_snowflake_datetime.py b/tests/test_snowflake_datetime.py index 85d1ce6975..69866da7fa 100644 --- a/tests/test_snowflake_datetime.py +++ b/tests/test_snowflake_datetime.py @@ -27,10 +27,7 @@ import pytest from discord import DiscordTime -from discord.utils import ( - DISCORD_EPOCH, - snowflake_time, -) +from discord.datetime import DISCORD_EPOCH UTC = datetime.timezone.utc @@ -68,14 +65,14 @@ def test_generate_snowflake_boundary_high(dt: datetime.datetime, expected_ms: in def test_snowflake_time_roundtrip_boundary(dt: datetime.datetime, _expected_ms: int) -> None: 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 snowflake_time(sf_low) == dt - assert snowflake_time(sf_high) == dt + 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 = DiscordTime.from_datetime(dt).generate_snowflake(mode="realistic") - assert snowflake_time(sf) == dt + assert DiscordTime.from_snowflake(sf) == dt def test_generate_snowflake_invalid_mode() -> None: From 2c996bc94308974971189628fde800d2dff5facd Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 15:57:11 +0100 Subject: [PATCH 07/26] Remove extraneous import --- discord/utils/public.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/discord/utils/public.py b/discord/utils/public.py index c52fe62357..ada94d7206 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -11,8 +11,6 @@ from enum import Enum, auto from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast -from discord import DiscordTime - if TYPE_CHECKING: from ..abc import Snowflake from ..commands.context import AutocompleteContext From 0d71c45aff957d1078eb1c58da4a769e25299a83 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 16:05:13 +0100 Subject: [PATCH 08/26] Add missing return --- discord/datetime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/datetime.py b/discord/datetime.py index 5545ca0a48..4cb694f1fc 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -105,8 +105,8 @@ def generate_snowflake( @classmethod def from_datetime(cls, dt: datetime.datetime) -> Self: - 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) + 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: From 408cdf5ed9bde1fe017d630ad52b4845df7deb81 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 16:58:00 +0100 Subject: [PATCH 09/26] =?UTF-8?q?=F0=9F=90=9B=20Fix=20import=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paillat-dev --- discord/audit_logs.py | 3 ++- discord/channel/base.py | 2 +- discord/emoji.py | 2 +- discord/guild.py | 3 ++- discord/interactions.py | 4 ++-- discord/invite.py | 2 +- discord/message.py | 3 ++- discord/object.py | 2 +- discord/onboarding.py | 3 +-- discord/partial_emoji.py | 2 +- discord/role.py | 4 +--- discord/scheduled_events.py | 3 ++- discord/sticker.py | 2 +- discord/user.py | 2 +- discord/webhook/async_.py | 3 ++- discord/widget.py | 2 +- 16 files changed, 22 insertions(+), 20 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index b91467e4b0..28b50231b6 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -30,7 +30,8 @@ from inspect import isawaitable from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, TypeVar -from . import enums, DiscordTime +from . import enums +from .datetime import DiscordTime from .asset import Asset from .automod import AutoModAction, AutoModTriggerMetadata from .colour import Colour diff --git a/discord/channel/base.py b/discord/channel/base.py index f18821b2ec..fdd85aedec 100644 --- a/discord/channel/base.py +++ b/discord/channel/base.py @@ -33,7 +33,7 @@ from typing_extensions import Self, TypeVar, override -from .. import DiscordTime +from ..datetime import DiscordTime from ..abc import Messageable, Snowflake, SnowflakeTime, User, _Overwrites, _purge_messages_helper from ..emoji import GuildEmoji, PartialEmoji from ..enums import ChannelType, InviteTarget, SortOrder, try_enum diff --git a/discord/emoji.py b/discord/emoji.py index ffdf23aa27..c74884d5b7 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, Any, Iterator -from . import DiscordTime +from .datetime import DiscordTime from .asset import Asset, AssetMixin from .partial_emoji import PartialEmoji, _EmojiTag from .user import User diff --git a/discord/guild.py b/discord/guild.py index 14ca1fda20..47db143fe6 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -45,7 +45,8 @@ from typing_extensions import Self, override -from . import abc, utils, DiscordTime +from . import abc, utils +from .datetime import DiscordTime from .asset import Asset from .automod import AutoModAction, AutoModRule, AutoModTriggerMetadata from .channel import * diff --git a/discord/interactions.py b/discord/interactions.py index 59eeaa9068..3c9c0adcb2 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -26,12 +26,12 @@ 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, DiscordTime +from . import utils +from .datetime import DiscordTime from .channel import ChannelType, PartialMessageable, _threaded_channel_factory from .enums import ( InteractionContextType, diff --git a/discord/invite.py b/discord/invite.py index 4ad408db14..b836c1805a 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, TypeVar, Union -from . import DiscordTime +from .datetime import DiscordTime from .appinfo import PartialAppInfo from .asset import Asset from .enums import ChannelType, InviteTarget, VerificationLevel, try_enum diff --git a/discord/message.py b/discord/message.py index 543a5fd33b..6e3ba2c09a 100644 --- a/discord/message.py +++ b/discord/message.py @@ -45,7 +45,8 @@ from typing_extensions import Self -from . import utils, DiscordTime +from . import utils +from .datetime import DiscordTime from .channel import PartialMessageable from .channel.thread import Thread from .components import _component_factory diff --git a/discord/object.py b/discord/object.py index 3a2c029601..0cccf1b6d1 100644 --- a/discord/object.py +++ b/discord/object.py @@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, SupportsInt, Union -from . import DiscordTime +from .datetime import DiscordTime from .mixins import Hashable if TYPE_CHECKING: diff --git a/discord/onboarding.py b/discord/onboarding.py index 84f54fe359..66119dbb23 100644 --- a/discord/onboarding.py +++ b/discord/onboarding.py @@ -27,9 +27,8 @@ from functools import cached_property from typing import TYPE_CHECKING, Any -from discord import utils, DiscordTime - from . import utils +from .datetime import DiscordTime from .enums import OnboardingMode, PromptType, try_enum from .partial_emoji import PartialEmoji from .utils import MISSING, find diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index c01cb544fb..e34f1a6d38 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -28,8 +28,8 @@ import re from typing import TYPE_CHECKING, Any, TypedDict, TypeVar -from . import utils, DiscordTime from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .errors import InvalidArgument from .utils.private import get_as_snowflake diff --git a/discord/role.py b/discord/role.py index cb62792a5e..a7e22a5874 100644 --- a/discord/role.py +++ b/discord/role.py @@ -30,7 +30,7 @@ from typing_extensions import Self -from . import DiscordTime +from .datetime import DiscordTime from .asset import Asset from .colour import Colour from .errors import InvalidArgument @@ -43,8 +43,6 @@ __all__ = ("RoleTags", "Role", "RoleColours") if TYPE_CHECKING: - import datetime - from .app.state import ConnectionState from .guild import Guild from .member import Member diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index b6ada03fe3..b400526e87 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -27,8 +27,9 @@ import datetime from typing import TYPE_CHECKING, Any -from . import utils, DiscordTime +from . import utils from .asset import Asset +from .datetime import DiscordTime from .enums import ( ScheduledEventLocationType, ScheduledEventPrivacyLevel, diff --git a/discord/sticker.py b/discord/sticker.py index 100103187f..12a06e1dd7 100644 --- a/discord/sticker.py +++ b/discord/sticker.py @@ -28,8 +28,8 @@ import unicodedata from typing import TYPE_CHECKING, Literal -from . import DiscordTime from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .enums import StickerFormatType, StickerType, try_enum from .errors import InvalidData from .mixins import Hashable diff --git a/discord/user.py b/discord/user.py index 6fe58a9a6a..6b23fd817b 100644 --- a/discord/user.py +++ b/discord/user.py @@ -29,11 +29,11 @@ from typing import TYPE_CHECKING, Any, TypeVar import discord.abc -from . import DiscordTime 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 diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index ca64ea6864..c01f7fa0f2 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -36,10 +36,11 @@ import aiohttp -from .. import utils, DiscordTime +from .. import utils 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, diff --git a/discord/widget.py b/discord/widget.py index 72f28ecf20..1b86aa5296 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -27,8 +27,8 @@ from typing import TYPE_CHECKING, Any -from . import DiscordTime 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 5fb5c49a9e6c10d6f13167a6c3e05d1003ffc9b1 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 17:13:58 +0100 Subject: [PATCH 10/26] Move format_dt to new subclass --- CHANGELOG-NEXT.md | 9 +++-- discord/datetime.py | 59 ++++++++++++++++++++++++---- discord/utils/__init__.py | 2 - discord/utils/public.py | 50 ----------------------- docs/api/utils.rst | 2 - examples/app_commands/info.py | 4 +- examples/app_commands/slash_basic.py | 2 +- tests/test_format_dt.py | 8 ++-- 8 files changed, 64 insertions(+), 72 deletions(-) diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index 131811fa6d..5143db4d19 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -5,7 +5,7 @@ release. ### Added -- `discord.datetime.Datetime` class, a `datetime.datetime` subclass that offers additional +- `discord.datetime.DiscordTime`, a `datetime.datetime` subclass that offers additional functionality for snowflakes and util methods. ### Fixed @@ -31,6 +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.Datetime` -- `utils.utcnow`, moved to `discord.datetime.Datetime` -- `utils.snowflake_time`, moved to `discord.datetime.Datetime` as `Datetime.from_snowflake` +- `utils.generate_snowflake`, moved to `discord.datetime.DiscordTime` +- `utils.utcnow`, moved to `discord.datetime.DiscordTime` +- `utils.snowflake_time`, moved to `discord.datetime.DiscordTime` as `DiscordTime.from_snowflake` +- `utils.format_dt`, moved to `discord.datetime.DiscordTime` as `DiscordTime.format_datetime` diff --git a/discord/datetime.py b/discord/datetime.py index 4cb694f1fc..2854c6cad8 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -23,15 +23,12 @@ """ import datetime -from typing import override, Literal +from typing import Literal -from typing_extensions import Self - -__all__ = ( - "DiscordTime", -) +from typing_extensions import Self, override DISCORD_EPOCH = 1420070400000 +TimestampStyle = Literal["f", "F", "d", "D", "t", "T", "R"] class DiscordTime(datetime.datetime): @@ -104,7 +101,9 @@ def generate_snowflake( raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") @classmethod - def from_datetime(cls, dt: datetime.datetime) -> Self: + def from_datetime(cls, dt: datetime.datetime | datetime.time) -> Self: + 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) @@ -124,3 +123,49 @@ def from_snowflake(cls, id: int) -> Self: """ timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) + + @classmethod + def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: + """A method to format this :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 + ---------- + style: :class:`str`R + The style to format the datetime with. + + Returns + ------- + :class:`str` + The formatted string. + """ + dt = cls + if isinstance(dt, datetime.time): + dt = datetime.datetime.combine(datetime.datetime.now(), dt) + if style is None: + return f"" + return f"" diff --git a/discord/utils/__init__.py b/discord/utils/__init__.py index 46c7d0d9ed..0e9db76211 100644 --- a/discord/utils/__init__.py +++ b/discord/utils/__init__.py @@ -34,7 +34,6 @@ escape_markdown, escape_mentions, find, - format_dt, oauth_url, raw_channel_mentions, raw_mentions, @@ -51,7 +50,6 @@ "raw_mentions", "raw_channel_mentions", "raw_role_mentions", - "format_dt", "basic_autocomplete", "Undefined", "MISSING", diff --git a/discord/utils/public.py b/discord/utils/public.py index ada94d7206..2f3a064e5b 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -184,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/docs/api/utils.rst b/docs/api/utils.rst index 2e8bbea8ce..0b433f3855 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -21,6 +21,4 @@ Utility Functions .. autofunction:: discord.utils.raw_role_mentions -.. autofunction:: discord.utils.format_dt - .. autofunction:: discord.utils.basic_autocomplete diff --git a/examples/app_commands/info.py b/examples/app_commands/info.py index 7040279d07..f6dbcdf6ea 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=discord.DiscordTime.from_datetime(user.created_at).format_datetime("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=discord.DiscordTime.from_datetime(user.joined_at).format_datetime("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..f25b96f87b 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 {discord.DiscordTime.from_datetime(user.joined_at).format_datetime()}") # To learn how to add descriptions and choices to options, check slash_options.py diff --git a/tests/test_format_dt.py b/tests/test_format_dt.py index fd673e428d..12c7de216c 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_datetime(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_datetime(style=style) dt = datetime.datetime.combine(today, tm) - result_dt = format_dt(dt, style=style) + result_dt = DiscordTime.from_datetime(dt).format_datetime(style=style) assert result_time == result_dt From d77f2b5750420cad5cfae8713f900d5b8abda8d8 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 17:33:06 +0100 Subject: [PATCH 11/26] Address ruff warnings --- discord/audit_logs.py | 2 +- discord/channel/base.py | 2 +- discord/datetime.py | 21 +++++++++++++++++++-- discord/emoji.py | 2 +- discord/guild.py | 2 +- discord/interactions.py | 2 +- discord/invite.py | 2 +- discord/message.py | 2 +- discord/partial_emoji.py | 1 + discord/role.py | 2 +- tests/test_snowflake_datetime.py | 3 +-- 11 files changed, 29 insertions(+), 12 deletions(-) diff --git a/discord/audit_logs.py b/discord/audit_logs.py index 28b50231b6..f2564c20ef 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -31,10 +31,10 @@ from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generator, TypeVar from . import enums -from .datetime import DiscordTime 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 diff --git a/discord/channel/base.py b/discord/channel/base.py index fdd85aedec..05aa0fb80a 100644 --- a/discord/channel/base.py +++ b/discord/channel/base.py @@ -33,8 +33,8 @@ from typing_extensions import Self, TypeVar, override -from ..datetime import DiscordTime 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 diff --git a/discord/datetime.py b/discord/datetime.py index 2854c6cad8..75fc5c1ddc 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -102,10 +102,27 @@ def generate_snowflake( @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. + + .. versionadded:: 3.0 + """ 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) + 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: diff --git a/discord/emoji.py b/discord/emoji.py index c74884d5b7..5ca929ebdc 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -27,8 +27,8 @@ from typing import TYPE_CHECKING, Any, Iterator -from .datetime import DiscordTime from .asset import Asset, AssetMixin +from .datetime import DiscordTime from .partial_emoji import PartialEmoji, _EmojiTag from .user import User from .utils import MISSING, Undefined diff --git a/discord/guild.py b/discord/guild.py index 47db143fe6..8e55c3cdba 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -46,13 +46,13 @@ from typing_extensions import Self, override from . import abc, utils -from .datetime import DiscordTime from .asset import Asset from .automod import AutoModAction, AutoModRule, AutoModTriggerMetadata from .channel import * 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, diff --git a/discord/interactions.py b/discord/interactions.py index 3c9c0adcb2..64904b6f97 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -31,8 +31,8 @@ from typing_extensions import Self, TypeVar, override, reveal_type from . import utils -from .datetime import DiscordTime from .channel import ChannelType, PartialMessageable, _threaded_channel_factory +from .datetime import DiscordTime from .enums import ( InteractionContextType, InteractionResponseType, diff --git a/discord/invite.py b/discord/invite.py index b836c1805a..9befef2520 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -27,9 +27,9 @@ from typing import TYPE_CHECKING, TypeVar, Union -from .datetime import DiscordTime 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 diff --git a/discord/message.py b/discord/message.py index 6e3ba2c09a..16126610fa 100644 --- a/discord/message.py +++ b/discord/message.py @@ -46,10 +46,10 @@ from typing_extensions import Self from . import utils -from .datetime import DiscordTime 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 diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index e34f1a6d38..5327580493 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -28,6 +28,7 @@ import re from typing import TYPE_CHECKING, Any, TypedDict, TypeVar +from . import utils from .asset import Asset, AssetMixin from .datetime import DiscordTime from .errors import InvalidArgument diff --git a/discord/role.py b/discord/role.py index a7e22a5874..4cb72b7455 100644 --- a/discord/role.py +++ b/discord/role.py @@ -30,9 +30,9 @@ from typing_extensions import Self -from .datetime import DiscordTime from .asset import Asset from .colour import Colour +from .datetime import DiscordTime from .errors import InvalidArgument from .flags import RoleFlags from .mixins import Hashable diff --git a/tests/test_snowflake_datetime.py b/tests/test_snowflake_datetime.py index 69866da7fa..f76b4c7c54 100644 --- a/tests/test_snowflake_datetime.py +++ b/tests/test_snowflake_datetime.py @@ -77,5 +77,4 @@ def test_snowflake_time_roundtrip_realistic(dt: datetime.datetime, _expected_ms: def test_generate_snowflake_invalid_mode() -> None: with pytest.raises(ValueError, match=r"Invalid mode 'nope'. Must be 'realistic' or 'boundary'"): - DiscordTime.from_datetime(datetime.datetime.now(tz=UTC)).generate_snowflake( - mode="nope") # ty: ignore[invalid-argument-type] + DiscordTime.from_datetime(datetime.datetime.now(tz=UTC)).generate_snowflake(mode="nope") # ty: ignore[invalid-argument-type] From b62d913d3cb0bba8eded5ee5d4d97d567b134b31 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 17:58:56 +0100 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=93=9D=20Add=20to=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discord/datetime.py | 7 +++---- docs/api/data_classes.rst | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/discord/datetime.py b/discord/datetime.py index 75fc5c1ddc..02040bba05 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -32,7 +32,7 @@ class DiscordTime(datetime.datetime): - """A subclass of `datetime.datetime` that offers additional utility methods + """A subclass of :class:`datetime.datetime` that offers additional utility methods .. versionadded:: 3.0 """ @@ -135,7 +135,7 @@ def from_snowflake(cls, id: int) -> Self: Returns ------- - :class:`discord.datetime.DiscordTime` + :class:`discord.DiscordTime` An aware datetime in UTC representing the creation time of the snowflake. """ timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 @@ -172,9 +172,8 @@ def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: Parameters ---------- - style: :class:`str`R + style: :class:`str` The style to format the datetime with. - Returns ------- :class:`str` diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index ad71d7deee..1a99fd1205 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -247,3 +247,20 @@ Application Role Connections .. autoclass:: ApplicationRoleConnectionMetadata :members: + +Datetime +-------- + +.. autoclass:: discord.DiscordTime + +.. autofunction:: discord.DiscordTime.format_datetime + +.. autofunction:: discord.DiscordTime.format_snowflake + +.. autofunction:: discord.DiscordTime.from_datetime + +.. autofunction:: discord.DiscordTime.from_snowflake + +.. autofunction:: discord.DiscordTime.generate_snowflake + +.. autofunction:: discord.DiscordTime.utcnow \ No newline at end of file From 571dc0ffa62f4f092b753cb8547b590f6a41a777 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 18:11:30 +0100 Subject: [PATCH 13/26] Minor formatting fix --- discord/datetime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/datetime.py b/discord/datetime.py index 02040bba05..8c9e8b4d94 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -174,6 +174,7 @@ def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: ---------- style: :class:`str` The style to format the datetime with. + Returns ------- :class:`str` From 52a7a04359fa7f7e70872de03abf44b85f873dd0 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 18:53:28 +0100 Subject: [PATCH 14/26] Add DiscordTime to a lot more places (not yet done) --- CHANGELOG-NEXT.md | 2 +- discord/app/state.py | 2 +- discord/channel/thread.py | 12 ++++++------ discord/datetime.py | 31 ++++++++++++++++++++++++++++++- discord/embeds.py | 5 ++--- discord/events/channel.py | 6 ++++-- discord/incidents.py | 22 +++++++++++----------- discord/integrations.py | 10 +++++----- discord/invite.py | 10 +++++----- discord/member.py | 27 ++++++++++++++------------- discord/message.py | 10 +++++----- discord/monetization.py | 17 +++++++++-------- discord/poll.py | 6 +++--- discord/template.py | 13 ++++++------- discord/utils/private.py | 26 -------------------------- 15 files changed, 102 insertions(+), 97 deletions(-) diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index 5143db4d19..1002ad9621 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -5,7 +5,7 @@ release. ### Added -- `discord.datetime.DiscordTime`, a `datetime.datetime` subclass that offers additional +- `discord.DiscordTime`, a `datetime.datetime` subclass that offers additional functionality for snowflakes and util methods. ### Fixed 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/channel/thread.py b/discord/channel/thread.py index 3a942dff86..697ae71bfe 100644 --- a/discord/channel/thread.py +++ b/discord/channel/thread.py @@ -29,7 +29,7 @@ from typing_extensions import override -from discord import utils +from discord import DiscordTime, utils from ..abc import Messageable, _purge_messages_helper from ..enums import ( @@ -42,7 +42,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,7 +128,7 @@ 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 An aware timestamp of when the thread was created. @@ -205,10 +205,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.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.parse_time(metadata.get("create_timestamp")) # Handle thread member data if "member" in data: @@ -889,7 +889,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.parse_time(data["join_timestamp"]) self.flags = data["flags"] @property diff --git a/discord/datetime.py b/discord/datetime.py index 8c9e8b4d94..98729c22ab 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -21,11 +21,12 @@ 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, override +from typing_extensions import Self, overload, override DISCORD_EPOCH = 1420070400000 TimestampStyle = Literal["f", "F", "d", "D", "t", "T", "R"] @@ -186,3 +187,31 @@ def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: 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..762d99bf91 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", @@ -438,7 +437,7 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: pass try: - self._timestamp = parse_time(data["timestamp"]) + self._timestamp = DiscordTime.parse_time(data["timestamp"]) except KeyError: pass diff --git a/discord/events/channel.py b/discord/events/channel.py index 5ae6255826..f3b4b38e01 100644 --- a/discord/events/channel.py +++ b/discord/events/channel.py @@ -35,7 +35,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") @@ -282,5 +284,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"]) if data["last_pin_timestamp"] else None return self diff --git a/discord/incidents.py b/discord/incidents.py index 5bf41485c8..f2e18dd902 100644 --- a/discord/incidents.py +++ b/discord/incidents.py @@ -27,7 +27,7 @@ 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 +40,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 +58,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: datetime.datetime | 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: datetime.datetime | 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: datetime.datetime | 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: datetime.datetime | 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..298645cf6d 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -28,13 +28,13 @@ import datetime from typing import TYPE_CHECKING, Any -from discord import utils +from discord import DiscordTime, utils 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 +188,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 +208,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 +289,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/invite.py b/discord/invite.py index 9befef2520..2256402957 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -33,7 +33,7 @@ from .enums import ChannelType, InviteTarget, VerificationLevel, try_enum from .mixins import Hashable from .object import Object -from .utils.private import get_as_snowflake, parse_time +from .utils.private import get_as_snowflake __all__ = ( "PartialInviteChannel", @@ -277,7 +277,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 +294,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 +356,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 +364,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/member.py b/discord/member.py index 04f69246af..e7a5078dda 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 @@ -316,8 +317,8 @@ class Member(discord.abc.Messageable, _UserTag): 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 16126610fa..673fecb2ae 100644 --- a/discord/message.py +++ b/discord/message.py @@ -65,7 +65,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 ( @@ -695,7 +695,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. @@ -706,7 +706,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 @@ -770,7 +770,7 @@ 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: DiscordTime | None = parse_time(data["edited_timestamp"]) + self._edited_timestamp: DiscordTime | None = DiscordTime.parse_time(data["edited_timestamp"]) @property def created_at(self) -> DiscordTime: @@ -1055,7 +1055,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)) diff --git a/discord/monetization.py b/discord/monetization.py index 8f708b47e6..644e9bee0a 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,8 @@ 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 +326,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/poll.py b/discord/poll.py index 278baaf047..5fac57827d 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -29,10 +29,10 @@ 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 +344,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/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/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, From 865a2d17da5f2aff2eefbdb7da4bc259448f2602 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 19:23:33 +0100 Subject: [PATCH 15/26] Hopefully replacing the final outgoing usages --- discord/activity.py | 35 +++++++++++++++++++---------------- discord/audit_logs.py | 2 +- discord/embeds.py | 8 +++++--- discord/emoji.py | 4 +--- discord/events/channel.py | 5 ++--- discord/events/typing.py | 7 +++---- discord/ext/tasks/__init__.py | 4 +++- discord/incidents.py | 9 ++++----- discord/invite.py | 4 +--- discord/member.py | 4 ++-- discord/message.py | 13 ++++++------- discord/poll.py | 1 - discord/scheduled_events.py | 10 +++++----- discord/utils/public.py | 1 - 14 files changed, 52 insertions(+), 55 deletions(-) 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/audit_logs.py b/discord/audit_logs.py index f2564c20ef..fb5baa74ae 100644 --- a/discord/audit_logs.py +++ b/discord/audit_logs.py @@ -586,7 +586,7 @@ 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 DiscordTime.from_snowflake(self.id) diff --git a/discord/embeds.py b/discord/embeds.py index 762d99bf91..7b1dd9ec8f 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -323,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. @@ -532,8 +532,10 @@ 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: + if not getattr(self, "_timestamp", None): + return None + return DiscordTime.from_datetime(self._timestamp) @timestamp.setter def timestamp(self, value: datetime.datetime | None): diff --git a/discord/emoji.py b/discord/emoji.py index 5ca929ebdc..29fa2824e0 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -41,8 +41,6 @@ ) if TYPE_CHECKING: - from datetime import datetime - from .abc import Snowflake from .app.state import ConnectionState from .guild import Guild @@ -101,7 +99,7 @@ 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 DiscordTime.from_snowflake(self.id) diff --git a/discord/events/channel.py b/discord/events/channel.py index f3b4b38e01..035a8652bd 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 @@ -259,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 diff --git a/discord/events/typing.py b/discord/events/typing.py index 3ab879ce2b..a3e89f5097 100644 --- a/discord/events/typing.py +++ b/discord/events/typing.py @@ -22,12 +22,11 @@ DEALINGS IN THE SOFTWARE. """ -from datetime import datetime from typing import TYPE_CHECKING, Any from typing_extensions import Self, override -from discord import utils +from discord import DiscordTime, utils from discord.app.event_emitter import Event from discord.app.state import ConnectionState from discord.channel import DMChannel, GroupChannel, TextChannel @@ -59,7 +58,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 +67,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/incidents.py b/discord/incidents.py index f2e18dd902..9503645236 100644 --- a/discord/incidents.py +++ b/discord/incidents.py @@ -24,7 +24,6 @@ from __future__ import annotations -import datetime from typing import TYPE_CHECKING from .datetime import DiscordTime @@ -58,13 +57,13 @@ class IncidentsData: ) def __init__(self, data: IncidentsDataPayload): - self.invites_disabled_until: datetime.datetime | None = DiscordTime.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 = DiscordTime.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 = DiscordTime.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 = DiscordTime.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/invite.py b/discord/invite.py index 2256402957..712dfdd0c4 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -57,8 +57,6 @@ InviteGuildType = Guild | "PartialInviteGuild" | Object InviteChannelType = GuildChannel | "PartialInviteChannel" | Object - import datetime - class PartialInviteChannel: """Represents a "partial" invite channel. @@ -189,7 +187,7 @@ 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 DiscordTime.from_snowflake(self.id) diff --git a/discord/member.py b/discord/member.py index e7a5078dda..b7a8710fcf 100644 --- a/discord/member.py +++ b/discord/member.py @@ -301,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 @@ -311,7 +311,7 @@ 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): diff --git a/discord/message.py b/discord/message.py index 673fecb2ae..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 @@ -266,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.""" @@ -828,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 @@ -837,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 diff --git a/discord/poll.py b/discord/poll.py index 5fac57827d..5dec86b808 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -24,7 +24,6 @@ from __future__ import annotations -import datetime from functools import cached_property from typing import TYPE_CHECKING, Any diff --git a/discord/scheduled_events.py b/discord/scheduled_events.py index b400526e87..4467180e15 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -147,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. @@ -201,10 +201,10 @@ 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")) + self.start_time: DiscordTime = DiscordTime.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 + end_time = DiscordTime.fromisoformat(end_time) + self.end_time: DiscordTime | None = 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") diff --git a/discord/utils/public.py b/discord/utils/public.py index 2f3a064e5b..57fc4e18bb 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 From 8e549ce539733fac3610a36eaf96a1073c3aa951 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 19:31:46 +0100 Subject: [PATCH 16/26] Final fixes so docs build and all --- discord/channel/thread.py | 3 ++- discord/events/typing.py | 4 +++- discord/integrations.py | 3 ++- docs/api/data_classes.rst | 4 +++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/discord/channel/thread.py b/discord/channel/thread.py index 697ae71bfe..d6838cf115 100644 --- a/discord/channel/thread.py +++ b/discord/channel/thread.py @@ -29,7 +29,8 @@ from typing_extensions import override -from discord import DiscordTime, utils +from discord import utils +from ..datetime import DiscordTime from ..abc import Messageable, _purge_messages_helper from ..enums import ( diff --git a/discord/events/typing.py b/discord/events/typing.py index a3e89f5097..31ffcd6704 100644 --- a/discord/events/typing.py +++ b/discord/events/typing.py @@ -26,7 +26,7 @@ from typing_extensions import Self, override -from discord import DiscordTime, utils +from discord import utils from discord.app.event_emitter import Event from discord.app.state import ConnectionState from discord.channel import DMChannel, GroupChannel, TextChannel @@ -35,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 diff --git a/discord/integrations.py b/discord/integrations.py index 298645cf6d..fcba3e0e35 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -28,8 +28,9 @@ import datetime from typing import TYPE_CHECKING, Any -from discord import DiscordTime, utils +from discord import utils +from .datetime import DiscordTime from .enums import ExpireBehaviour, try_enum from .errors import InvalidArgument from .user import User diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 1a99fd1205..7538578510 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -263,4 +263,6 @@ Datetime .. autofunction:: discord.DiscordTime.generate_snowflake -.. autofunction:: discord.DiscordTime.utcnow \ No newline at end of file +.. autofunction:: discord.DiscordTime.utcnow + +.. autofunction:: discord.DiscordTime.parse_time \ No newline at end of file From b355cecaf20b9559356395db8d3372b312bb5687 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 19:36:36 +0100 Subject: [PATCH 17/26] Minor docs improvements --- discord/datetime.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/discord/datetime.py b/discord/datetime.py index 98729c22ab..5aed8ae807 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -81,16 +81,21 @@ def generate_snowflake( Examples -------- - # Generate realistic snowflake - snowflake = DateTime.utcnow().generate_snowflake() + .. code-block:: python - # Generate boundary snowflakes - lower_bound = DateTime.utcnow().generate_snowflake(mode="boundary", high=False) - upper_bound = DateTime.utcnow().generate_snowflake(mode="boundary", high=True) + # 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 - # 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) @@ -109,8 +114,6 @@ def from_datetime(cls, dt: datetime.datetime | datetime.time) -> Self: ---------- dt: :class:`datetime.datetime` | :class:`datetime.time` A datetime or time object to generate a DiscordTime from. - - .. versionadded:: 3.0 """ if isinstance(dt, datetime.time): dt = datetime.datetime.combine(cls.utcnow(), dt) From dc10f3b8cb9783e5a8ac13215357e9fba35757bf Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 19:59:50 +0100 Subject: [PATCH 18/26] Fix final ruff error, ruff ruff --- discord/channel/thread.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/channel/thread.py b/discord/channel/thread.py index d6838cf115..58db83455e 100644 --- a/discord/channel/thread.py +++ b/discord/channel/thread.py @@ -30,9 +30,9 @@ from typing_extensions import override from discord import utils -from ..datetime import DiscordTime from ..abc import Messageable, _purge_messages_helper +from ..datetime import DiscordTime from ..enums import ( ChannelType, try_enum, From b5db89aa871bef677a2050a635beaa7ac539da59 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 20:30:57 +0100 Subject: [PATCH 19/26] Fix ruff for good and fix format_datetime --- discord/datetime.py | 27 +++++++++++---------------- discord/monetization.py | 4 +++- discord/utils/public.py | 1 + 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/discord/datetime.py b/discord/datetime.py index 5aed8ae807..16e0bb2bad 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -21,6 +21,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ + from __future__ import annotations import datetime @@ -54,10 +55,10 @@ def utcnow(cls) -> Self: return cls.now(datetime.UTC) def generate_snowflake( - self, - *, - mode: Literal["boundary", "realistic"] = "boundary", - high: bool = False, + self, + *, + mode: Literal["boundary", "realistic"] = "boundary", + high: bool = False, ) -> int: """Returns a numeric snowflake pretending to be created at the given date. @@ -102,7 +103,7 @@ def generate_snowflake( if mode == "realistic": return (discord_millis << 22) | 0x3FFFFF elif mode == "boundary": - return (discord_millis << 22) + (2 ** 22 - 1 if high else 0) + return (discord_millis << 22) + (2**22 - 1 if high else 0) else: raise ValueError(f"Invalid mode '{mode}'. Must be 'realistic' or 'boundary'") @@ -145,8 +146,7 @@ def from_snowflake(cls, id: int) -> Self: timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) - @classmethod - def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: + def format_datetime(self, /, style: TimestampStyle | None = None) -> str: """A method to format this :class:`datetime.datetime` for presentation within Discord. This allows for a locale-independent way of presenting data using Discord specific Markdown. @@ -184,22 +184,17 @@ def format_datetime(cls, /, style: TimestampStyle | None = None) -> str: :class:`str` The formatted string. """ - dt = cls - if isinstance(dt, datetime.time): - dt = datetime.datetime.combine(datetime.datetime.now(), dt) if style is None: - return f"" - return f"" + return f"" + return f"" @overload @classmethod - def parse_time(cls, timestamp: None) -> None: - ... + def parse_time(cls, timestamp: None) -> None: ... @overload @classmethod - def parse_time(cls, timestamp: str) -> DiscordTime: - ... + def parse_time(cls, timestamp: str) -> DiscordTime: ... @classmethod def parse_time(cls, timestamp: str | None) -> DiscordTime | None: diff --git a/discord/monetization.py b/discord/monetization.py index 644e9bee0a..2577e2d3cb 100644 --- a/discord/monetization.py +++ b/discord/monetization.py @@ -232,7 +232,9 @@ def __init__(self, *, data: EntitlementPayload, state: ConnectionState) -> None: self.type: EntitlementType = try_enum(EntitlementType, data["type"]) self.deleted: bool = data["deleted"] 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.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) diff --git a/discord/utils/public.py b/discord/utils/public.py index 57fc4e18bb..eeaaf1fd72 100644 --- a/discord/utils/public.py +++ b/discord/utils/public.py @@ -131,6 +131,7 @@ def _filter(ctx: AutocompleteContext, item: OptionChoice | str | int | float) -> return autocomplete_callback + def oauth_url( client_id: int | str, *, From 4132d73f31981036fba8c25b99227b93db6ccba8 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 20:53:11 +0100 Subject: [PATCH 20/26] Apply change requests Co-authored-by: NeloBlivion <41271523+neloblivion@users.noreply.github.com> --- discord/channel/thread.py | 12 ++++++------ discord/datetime.py | 4 ++-- discord/events/channel.py | 2 +- discord/scheduled_events.py | 4 +--- examples/app_commands/info.py | 4 ++-- examples/app_commands/slash_basic.py | 2 +- tests/test_format_dt.py | 6 +++--- 7 files changed, 16 insertions(+), 18 deletions(-) diff --git a/discord/channel/thread.py b/discord/channel/thread.py index 58db83455e..2b8ac80105 100644 --- a/discord/channel/thread.py +++ b/discord/channel/thread.py @@ -129,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: discord.discordTime + 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 @@ -206,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 = DiscordTime.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 = DiscordTime.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: @@ -857,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. """ @@ -890,7 +890,7 @@ def _from_data(self, data: ThreadMemberPayload): except KeyError: self.thread_id = self.parent.id - self.joined_at = DiscordTime.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/datetime.py b/discord/datetime.py index 16e0bb2bad..f4c8d490a8 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -146,8 +146,8 @@ def from_snowflake(cls, id: int) -> Self: timestamp = ((id >> 22) + DISCORD_EPOCH) / 1000 return DiscordTime.fromtimestamp(timestamp, tz=datetime.timezone.utc) - def format_datetime(self, /, style: TimestampStyle | None = None) -> str: - """A method to format this :class:`datetime.datetime` for presentation within Discord. + 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. diff --git a/discord/events/channel.py b/discord/events/channel.py index 035a8652bd..21616f56a3 100644 --- a/discord/events/channel.py +++ b/discord/events/channel.py @@ -283,5 +283,5 @@ async def __load__(cls, data: dict[str, Any], state: ConnectionState) -> Self | self = cls() self.channel = channel - self.last_pin = DiscordTime.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/scheduled_events.py b/discord/scheduled_events.py index 4467180e15..1abb928d14 100644 --- a/discord/scheduled_events.py +++ b/discord/scheduled_events.py @@ -202,9 +202,7 @@ def __init__( self.description: str | None = data.get("description", None) self._image: str | None = data.get("image", None) self.start_time: DiscordTime = DiscordTime.fromisoformat(data.get("scheduled_start_time")) - if end_time := data.get("scheduled_end_time", None): - end_time = DiscordTime.fromisoformat(end_time) - self.end_time: DiscordTime | None = end_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") diff --git a/examples/app_commands/info.py b/examples/app_commands/info.py index f6dbcdf6ea..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.DiscordTime.from_datetime(user.created_at).format_datetime("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.DiscordTime.from_datetime(user.joined_at).format_datetime("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 f25b96f87b..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.DiscordTime.from_datetime(user.joined_at).format_datetime()}") + 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/tests/test_format_dt.py b/tests/test_format_dt.py index 12c7de216c..ae1b406b0a 100644 --- a/tests/test_format_dt.py +++ b/tests/test_format_dt.py @@ -71,7 +71,7 @@ def test_format_dt_formats_datetime( expected = f"" else: expected = f"" - result = DiscordTime.from_datetime(dt).format_datetime(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 = DiscordTime.from_datetime(tm).format_datetime(style=style) + result_time = DiscordTime.from_datetime(tm).format(style=style) dt = datetime.datetime.combine(today, tm) - result_dt = DiscordTime.from_datetime(dt).format_datetime(style=style) + result_dt = DiscordTime.from_datetime(dt).format(style=style) assert result_time == result_dt From da1606cd4854165052b5ffa0dccb7ef4ef00042d Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Fri, 2 Jan 2026 21:07:23 +0100 Subject: [PATCH 21/26] Restore 3.10 compat --- discord/datetime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/datetime.py b/discord/datetime.py index f4c8d490a8..28fb080bf5 100644 --- a/discord/datetime.py +++ b/discord/datetime.py @@ -52,7 +52,7 @@ def utcnow(cls) -> Self: :class:`discord.DiscordTime` The current aware datetime in UTC. """ - return cls.now(datetime.UTC) + return cls.now(datetime.timezone.utc) def generate_snowflake( self, From 6ae732082212ca7777b86cf87324bf3f96c83456 Mon Sep 17 00:00:00 2001 From: ToothyDev <55001472+ToothyDev@users.noreply.github.com> Date: Fri, 2 Jan 2026 21:14:04 +0100 Subject: [PATCH 22/26] Update docs/api/data_classes.rst Co-authored-by: DA344 <108473820+DA-344@users.noreply.github.com> Signed-off-by: ToothyDev <55001472+ToothyDev@users.noreply.github.com> --- docs/api/data_classes.rst | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index 7538578510..57812a0296 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -252,17 +252,4 @@ Datetime -------- .. autoclass:: discord.DiscordTime - -.. autofunction:: discord.DiscordTime.format_datetime - -.. autofunction:: discord.DiscordTime.format_snowflake - -.. autofunction:: discord.DiscordTime.from_datetime - -.. autofunction:: discord.DiscordTime.from_snowflake - -.. autofunction:: discord.DiscordTime.generate_snowflake - -.. autofunction:: discord.DiscordTime.utcnow - -.. autofunction:: discord.DiscordTime.parse_time \ No newline at end of file + :members: \ No newline at end of file From ab7bb90942a166253fd859d63036227ab2a4992c Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Sat, 3 Jan 2026 14:02:43 +0100 Subject: [PATCH 23/26] Apply change requests Co-authored-by: Paillat-dev --- CHANGELOG-NEXT.md | 10 +++++----- discord/embeds.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG-NEXT.md b/CHANGELOG-NEXT.md index 1002ad9621..6514a38b3b 100644 --- a/CHANGELOG-NEXT.md +++ b/CHANGELOG-NEXT.md @@ -6,7 +6,7 @@ release. ### Added - `discord.DiscordTime`, a `datetime.datetime` subclass that offers additional - functionality for snowflakes and util methods. + functionality for snowflakes as well as util methods. ### Fixed @@ -31,7 +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` -- `utils.utcnow`, moved to `discord.datetime.DiscordTime` -- `utils.snowflake_time`, moved to `discord.datetime.DiscordTime` as `DiscordTime.from_snowflake` -- `utils.format_dt`, moved to `discord.datetime.DiscordTime` as `DiscordTime.format_datetime` +- `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/embeds.py b/discord/embeds.py index 7b1dd9ec8f..b38cb9323b 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -535,14 +535,14 @@ def colour(self, value: int | Colour | None): # type: ignore def timestamp(self) -> DiscordTime | None: if not getattr(self, "_timestamp", None): return None - return DiscordTime.from_datetime(self._timestamp) + 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: From 83401c47a9b6f9bd32864458467ce2c8662dc67a Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Sat, 3 Jan 2026 15:25:25 +0100 Subject: [PATCH 24/26] Simplify timestamp getter --- discord/embeds.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/embeds.py b/discord/embeds.py index b38cb9323b..2e5f307fec 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -533,9 +533,7 @@ def colour(self, value: int | Colour | None): # type: ignore @property def timestamp(self) -> DiscordTime | None: - if not getattr(self, "_timestamp", None): - return None - return self._timestamp + return getattr(self, "_timestamp", None) @timestamp.setter def timestamp(self, value: datetime.datetime | None): From c8b5ba7b057391b122443d0f0f749847b3a82530 Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Sat, 3 Jan 2026 17:15:47 +0100 Subject: [PATCH 25/26] Apply change requests Co-authored-by: Paillat-dev --- discord/embeds.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/embeds.py b/discord/embeds.py index 2e5f307fec..99285995ce 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -436,10 +436,10 @@ def from_dict(cls: type[E], data: Mapping[str, Any]) -> E: except KeyError: pass - try: - self._timestamp = DiscordTime.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", From 8ac36da9041b5e3bb97d1bbd39b9b48a10c317bb Mon Sep 17 00:00:00 2001 From: ToothyDev Date: Sat, 3 Jan 2026 17:29:44 +0100 Subject: [PATCH 26/26] Apply change requests Co-authored-by: Paillat-dev --- discord/embeds.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/discord/embeds.py b/discord/embeds.py index 99285995ce..0f45272ed5 100644 --- a/discord/embeds.py +++ b/discord/embeds.py @@ -379,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 [] @@ -533,7 +532,7 @@ def colour(self, value: int | Colour | None): # type: ignore @property def timestamp(self) -> DiscordTime | None: - return getattr(self, "_timestamp", None) + return self._timestamp @timestamp.setter def timestamp(self, value: datetime.datetime | None):