Repository: hikari-py/hikari
Pull request #2271 (2025-03-30 05:22:00)

Add support for member guild banners.
### Summary
Adds support for guild specific member banners per [discord-api-docs/ba64f0e](https://github.com/discord/discord-api-docs/commit/ba64f0efc85d261b618915db412d63d69111ce57) and [reference/image-formatting](https://discord.com/developers/docs/reference#image-formatting).

I'm not super familiar with the package internals so I may have missed something. I pretty much just duplicated and adjusted everything belonging to member guild avatars for member guild banners and then let the errors from running nox help me find anything I missed until I got no more errors.

### Checklist
<!-- Make sure to tick all the following boxes by putting an `x` in between (like this `[x]`) -->
- [✓] I have run `nox` and all the pipelines have passed.
- [✓] I have made unittests according to the code I have added/modified/deleted.


Patch:
diff --git a/changes/2271.feature.md b/changes/2271.feature.md
new file mode 100644
index 00000000..b0bd94d0
--- /dev/null
+++ b/changes/2271.feature.md
@@ -0,0 +1,1 @@
+Added support for guild specific member banners.
diff --git a/hikari/guilds.py b/hikari/guilds.py
index 79d15e52..c2789018 100644
--- a/hikari/guilds.py
+++ b/hikari/guilds.py
@@ -427,12 +427,19 @@ class Member(users.User):
     """This member's corresponding user object."""
 
     guild_avatar_hash: typing.Optional[str] = attrs.field(eq=False, hash=False, repr=False)
-    """Hash of the member's guild avatar guild if set, else [`None`][].
+    """Hash of the member's guild avatar if set, else [`None`][].
 
     !!! note
         This takes precedence over [`hikari.guilds.Member.avatar_hash`][].
     """
 
+    guild_banner_hash: typing.Optional[str] = attrs.field(eq=False, hash=False, repr=False)
+    """Hash of the member's guild banner if set, else [`None`][].
+
+    !!! note
+        This takes precedence over [`hikari.guilds.Member.banner_hash`][].
+    """
+
     guild_flags: typing.Union[GuildMemberFlags, int] = attrs.field(eq=False, hash=False, repr=False)
     """The flags this member has in the guild."""
 
@@ -474,6 +481,19 @@ def banner_hash(self) -> typing.Optional[str]:
     def banner_url(self) -> typing.Optional[files.URL]:
         return self.user.banner_url
 
+    @property
+    def guild_banner_url(self) -> typing.Optional[files.URL]:
+        """Guild Banner URL for the user, if they have one set.
+
+        May be [`None`][] if no guild banner is set. In this case, you
+        should use [`hikari.guilds.Member.banner_hash`][] instead.
+        """
+        return self.make_guild_banner_url()
+
+    @property
+    def display_banner_url(self) -> typing.Optional[files.URL]:
+        return self.make_guild_banner_url() or super().display_banner_url
+
     @property
     def accent_color(self) -> typing.Optional[colors.Color]:
         return self.user.accent_color
@@ -655,6 +675,53 @@ def make_guild_avatar_url(
             file_format=ext,
         )
 
+    def make_banner_url(self, *, ext: typing.Optional[str] = None, size: int = 4096) -> typing.Optional[files.URL]:
+        return self.user.make_banner_url(ext=ext, size=size)
+
+    def make_guild_banner_url(
+        self, *, ext: typing.Optional[str] = None, size: int = 4096
+    ) -> typing.Optional[files.URL]:
+        """Generate the guild specific banner url for this member, if set.
+
+        If no guild banner is set, this returns [`None`][].
+
+        Parameters
+        ----------
+        ext
+            The ext to use for this URL.
+            Supports `png`, `jpeg`, `jpg` and `webp`.
+
+            If [`None`][], then the correct default extension is
+            determined based on whether the banner is animated or not.
+        size
+            The size to set for the URL.
+            Can be any power of two between `16` and `4096`.
+
+        Returns
+        -------
+        typing.Optional[hikari.files.URL]
+            The URL to the banner, or [`None`][] if not present.
+
+        Raises
+        ------
+        ValueError
+            If `size` is not a power of two or not between 16 and 4096.
+        """
+        if self.guild_banner_hash is None:
+            return None
+
+        if ext is None:
+            ext = "gif" if self.guild_banner_hash.startswith("a_") else "png"
+
+        return routes.CDN_MEMBER_BANNER.compile_to_file(
+            urls.CDN_URL,
+            guild_id=self.guild_id,
+            user_id=self.id,
+            hash=self.guild_banner_hash,
+            size=size,
+            file_format=ext,
+        )
+
     async def fetch_self(self) -> Member:
         """Fetch an up-to-date view of this member from the API.
 
diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py
index 1dbecbce..a275c52e 100644
--- a/hikari/impl/entity_factory.py
+++ b/hikari/impl/entity_factory.py
@@ -1864,6 +1864,7 @@ def deserialize_member(
             joined_at=joined_at,
             nickname=payload.get("nick"),
             guild_avatar_hash=payload.get("avatar"),
+            guild_banner_hash=payload.get("banner"),
             premium_since=premium_since,
             is_deaf=payload.get("deaf", undefined.UNDEFINED),
             is_mute=payload.get("mute", undefined.UNDEFINED),
@@ -2502,6 +2503,7 @@ def _deserialize_interaction_member(
             joined_at=time.iso8601_datetime_string_to_datetime(payload["joined_at"]),
             premium_since=premium_since,
             guild_avatar_hash=payload.get("avatar"),
+            guild_banner_hash=payload.get("banner"),
             nickname=payload.get("nick"),
             is_deaf=payload.get("deaf", undefined.UNDEFINED),
             is_mute=payload.get("mute", undefined.UNDEFINED),
diff --git a/hikari/internal/cache.py b/hikari/internal/cache.py
index 2a194e21..d9c934df 100644
--- a/hikari/internal/cache.py
+++ b/hikari/internal/cache.py
@@ -396,6 +396,7 @@ class MemberData(BaseData[guilds.Member]):
     guild_id: snowflakes.Snowflake = attrs.field()
     nickname: typing.Optional[str] = attrs.field()
     guild_avatar_hash: typing.Optional[str] = attrs.field()
+    guild_banner_hash: typing.Optional[str] = attrs.field()
     role_ids: tuple[snowflakes.Snowflake, ...] = attrs.field()
     joined_at: typing.Optional[datetime.datetime] = attrs.field()
     premium_since: typing.Optional[datetime.datetime] = attrs.field()
@@ -417,6 +418,7 @@ def build_from_entity(
             joined_at=member.joined_at,
             premium_since=member.premium_since,
             guild_avatar_hash=member.guild_avatar_hash,
+            guild_banner_hash=member.guild_banner_hash,
             is_deaf=member.is_deaf,
             is_mute=member.is_mute,
             is_pending=member.is_pending,
@@ -435,6 +437,7 @@ def build_entity(self, _: traits.RESTAware, /) -> guilds.Member:
             role_ids=self.role_ids,
             joined_at=self.joined_at,
             guild_avatar_hash=self.guild_avatar_hash,
+            guild_banner_hash=self.guild_banner_hash,
             premium_since=self.premium_since,
             is_deaf=self.is_deaf,
             is_mute=self.is_mute,
diff --git a/hikari/internal/routes.py b/hikari/internal/routes.py
index 2df51812..c901ddca 100644
--- a/hikari/internal/routes.py
+++ b/hikari/internal/routes.py
@@ -608,6 +608,9 @@ def compile_to_file(
 CDN_MEMBER_AVATAR: typing.Final[CDNRoute] = CDNRoute(
     "/guilds/{guild_id}/users/{user_id}/avatars/{hash}", {PNG, *JPEG_JPG, WEBP, GIF}
 )
+CDN_MEMBER_BANNER: typing.Final[CDNRoute] = CDNRoute(
+    "/guilds/{guild_id}/users/{user_id}/banners/{hash}", {PNG, *JPEG_JPG, WEBP, GIF}
+)
 CDN_ROLE_ICON: typing.Final[CDNRoute] = CDNRoute("/role-icons/{role_id}/{hash}", {PNG, *JPEG_JPG, WEBP})
 
 CDN_APPLICATION_ICON: typing.Final[CDNRoute] = CDNRoute("/app-icons/{application_id}/{hash}", {PNG, *JPEG_JPG, WEBP})
diff --git a/hikari/users.py b/hikari/users.py
index bc374bbf..9c9a73ed 100644
--- a/hikari/users.py
+++ b/hikari/users.py
@@ -521,6 +521,11 @@ def display_avatar_url(self) -> files.URL:
         """Display avatar URL for this user."""
         return self.make_avatar_url() or self.default_avatar_url
 
+    @property
+    def display_banner_url(self) -> typing.Optional[files.URL]:
+        """Display banner URL for this user."""
+        return self.make_banner_url()
+
     @property
     @abc.abstractmethod
     def discriminator(self) -> str:
diff --git a/pyproject.toml b/pyproject.toml
index 79a6bfb0..42e33a41 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,7 +44,6 @@ readme = "README.md"
 authors = [
     { name = "Nekokatt" },
     { name = "davfsa" },
-    { name = "FasterSpeeding" },
 ]
 maintainers = [{ name = "davfsa", email = "davfsa@hikari-py.dev" }]
 classifiers = [


Test patch:
diff --git a/tests/hikari/impl/test_cache.py b/tests/hikari/impl/test_cache.py
index 5d3153e8..e578456d 100644
--- a/tests/hikari/impl/test_cache.py
+++ b/tests/hikari/impl/test_cache.py
@@ -1862,6 +1862,7 @@ def test__build_member(self, cache_impl):
             guild_id=snowflakes.Snowflake(6434435234),
             nickname="NICK",
             guild_avatar_hash="only slightly gay",
+            guild_banner_hash="ok maybe alotta gay",
             role_ids=(snowflakes.Snowflake(65234), snowflakes.Snowflake(654234123)),
             joined_at=datetime.datetime(2020, 7, 9, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc),
             premium_since=datetime.datetime(2020, 7, 17, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc),
@@ -1881,6 +1882,7 @@ def test__build_member(self, cache_impl):
         assert member.guild_id == 6434435234
         assert member.nickname == "NICK"
         assert member.guild_avatar_hash == "only slightly gay"
+        assert member.guild_banner_hash == "ok maybe alotta gay"
         assert member.role_ids == (snowflakes.Snowflake(65234), snowflakes.Snowflake(654234123))
         assert member.joined_at == datetime.datetime(2020, 7, 9, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc)
         assert member.premium_since == datetime.datetime(2020, 7, 17, 13, 11, 18, 384554, tzinfo=datetime.timezone.utc)
@@ -2218,6 +2220,7 @@ def test_set_member(self, cache_impl):
             premium_since=datetime.datetime(2020, 7, 1, 2, 0, 12, 501602, tzinfo=datetime.timezone.utc),
             is_deaf=True,
             guild_avatar_hash="gay",
+            guild_banner_hash="gayge",
             is_mute=False,
             is_pending=True,
             raw_communication_disabled_until=datetime.datetime(
@@ -2243,6 +2246,7 @@ def test_set_member(self, cache_impl):
         assert member_entry.object.role_ids == (65345234, 123123)
         assert member_entry.object.role_ids is not member_model.role_ids
         assert member_entry.object.guild_avatar_hash == "gay"
+        assert member_entry.object.guild_banner_hash == "gayge"
         assert isinstance(member_entry.object.role_ids, tuple)
         assert member_entry.object.joined_at == datetime.datetime(
             2020, 7, 15, 23, 30, 59, 501602, tzinfo=datetime.timezone.utc
diff --git a/tests/hikari/integration/test_equality_comparisons.py b/tests/hikari/integration/test_equality_comparisons.py
index 3b5d0cda..1744e5b1 100644
--- a/tests/hikari/integration/test_equality_comparisons.py
+++ b/tests/hikari/integration/test_equality_comparisons.py
@@ -67,6 +67,7 @@ def make_guild_member(user_id):
         nickname=user.username,
         premium_since=None,
         guild_avatar_hash="no",
+        guild_banner_hash="yes",
         is_deaf=False,
         is_mute=False,
         is_pending=False,
diff --git a/tests/hikari/test_guilds.py b/tests/hikari/test_guilds.py
index a8d65732..d8ad9a1e 100644
--- a/tests/hikari/test_guilds.py
+++ b/tests/hikari/test_guilds.py
@@ -226,6 +226,7 @@ def model(self, mock_user):
             joined_at=datetime.datetime.now().astimezone(),
             nickname="davb",
             guild_avatar_hash="dab",
+            guild_banner_hash="dimma",
             premium_since=None,
             role_ids=[snowflakes.Snowflake(456), snowflakes.Snowflake(1234)],
             user=mock_user,
@@ -272,6 +273,15 @@ def test_display_guild_avatar_url_when_guild_hash_is_not_None(self, model, mock_
             with mock.patch.object(users.User, "display_avatar_url") as mock_display_avatar_url:
                 assert model.display_avatar_url is mock_display_avatar_url
 
+    def test_display_banner_url_when_guild_hash_is_None(self, model, mock_user):
+        with mock.patch.object(guilds.Member, "make_guild_banner_url") as mock_make_guild_banner_url:
+            assert model.display_banner_url is mock_make_guild_banner_url.return_value
+
+    def test_display_banner_url_when_guild_hash_is_not_None(self, model, mock_user):
+        with mock.patch.object(guilds.Member, "make_guild_banner_url", return_value=None):
+            with mock.patch.object(users.User, "display_banner_url") as mock_display_banner_url:
+                assert model.display_banner_url is mock_display_banner_url
+
     def test_banner_hash_property(self, model, mock_user):
         assert model.banner_hash is mock_user.banner_hash
 
@@ -285,6 +295,10 @@ def test_guild_avatar_url_property(self, model):
         with mock.patch.object(guilds.Member, "make_guild_avatar_url") as make_guild_avatar_url:
             assert model.guild_avatar_url is make_guild_avatar_url.return_value
 
+    def test_guild_banner_url_property(self, model):
+        with mock.patch.object(guilds.Member, "make_guild_banner_url") as make_guild_banner_url:
+            assert model.guild_banner_url is make_guild_banner_url.return_value
+
     def test_communication_disabled_until(self, model):
         model.raw_communication_disabled_until = datetime.datetime(2021, 11, 22)
 
@@ -363,6 +377,66 @@ def test_make_guild_avatar_url_with_all_args(self, model):
             file_format="url",
         )
 
+    def test_make_banner_url(self, model, mock_user):
+        result = model.make_banner_url(ext="png", size=4096)
+        mock_user.make_banner_url.assert_called_once_with(ext="png", size=4096)
+        assert result is mock_user.make_banner_url.return_value
+
+    def test_make_guild_banner_url_when_no_hash(self, model):
+        model.guild_banner_hash = None
+        assert model.make_guild_banner_url(ext="png", size=1024) is None
+
+    def test_make_guild_banner_url_when_format_is_None_and_banner_hash_is_for_gif(self, model):
+        model.guild_banner_hash = "a_18dnf8dfbakfdh"
+
+        with mock.patch.object(
+            routes, "CDN_MEMBER_BANNER", new=mock.Mock(compile_to_file=mock.Mock(return_value="file"))
+        ) as route:
+            assert model.make_guild_banner_url(ext=None, size=4096) == "file"
+
+        route.compile_to_file.assert_called_once_with(
+            urls.CDN_URL,
+            user_id=model.id,
+            guild_id=model.guild_id,
+            hash=model.guild_banner_hash,
+            size=4096,
+            file_format="gif",
+        )
+
+    def test_make_guild_banner_url_when_format_is_None_and_banner_hash_is_not_for_gif(self, model):
+        model.guild_banner_hash = "18dnf8dfbakfdh"
+
+        with mock.patch.object(
+            routes, "CDN_MEMBER_BANNER", new=mock.Mock(compile_to_file=mock.Mock(return_value="file"))
+        ) as route:
+            assert model.make_guild_banner_url(ext=None, size=4096) == "file"
+
+        route.compile_to_file.assert_called_once_with(
+            urls.CDN_URL,
+            user_id=model.id,
+            guild_id=model.guild_id,
+            hash=model.guild_banner_hash,
+            size=4096,
+            file_format="png",
+        )
+
+    def test_make_guild_banner_url_with_all_args(self, model):
+        model.guild_banner_hash = "18dnf8dfbakfdh"
+
+        with mock.patch.object(
+            routes, "CDN_MEMBER_BANNER", new=mock.Mock(compile_to_file=mock.Mock(return_value="file"))
+        ) as route:
+            assert model.make_guild_banner_url(ext="url", size=4096) == "file"
+
+        route.compile_to_file.assert_called_once_with(
+            urls.CDN_URL,
+            guild_id=model.guild_id,
+            user_id=model.id,
+            hash=model.guild_banner_hash,
+            size=4096,
+            file_format="url",
+        )
+
     @pytest.mark.asyncio
     async def test_fetch_dm_channel(self, model):
         model.user.fetch_dm_channel = mock.AsyncMock()
diff --git a/tests/hikari/test_users.py b/tests/hikari/test_users.py
index 108b7885..e019e0b2 100644
--- a/tests/hikari/test_users.py
+++ b/tests/hikari/test_users.py
@@ -246,6 +246,14 @@ def test_display_avatar_url_when_no_avatar_url(self, obj):
             with mock.patch.object(users.User, "default_avatar_url") as mock_default_avatar_url:
                 assert obj.display_avatar_url is mock_default_avatar_url
 
+    def test_display_banner_url_when_banner_url(self, obj):
+        with mock.patch.object(users.User, "make_banner_url") as mock_make_banner_url:
+            assert obj.display_banner_url is mock_make_banner_url.return_value
+
+    def test_display_banner_url_when_no_banner_url(self, obj):
+        with mock.patch.object(users.User, "make_banner_url", return_value=None):
+            assert obj.display_banner_url is None
+
     def test_default_avatar(self, obj):
         obj.avatar_hash = "18dnf8dfbakfdh"
         obj.discriminator = "1234"