Repository: Fortnite-API/py-wrapper

Issue:
[BETA] Add new display asset endpoint
The wrapper currently doesn't support [beta/newdisplayassets](https://fortnite-api.com/beta/newdisplayassets). Support should be added as a beta endpoint.

This may require a breaking change to prevent duplicated `NewDisplayAsset` objects, since there is already a `ShopEntryNewDisplayAsset`. We probably want to create a common `NewDisplayAsset` object within it's own file.

Discussion:

Patch:
diff --git a/docs/api/objects.rst b/docs/api/objects.rst
index 64f7d85..1707084 100644
--- a/docs/api/objects.rst
+++ b/docs/api/objects.rst
@@ -47,6 +47,15 @@ The API has many objects that represent different parts of the Fortnite API. Bel
 .. autoclass:: fortnite_api.POILocation
     :members:
 
+.. autoclass:: fortnite_api.NewCosmetic
+    :members:
+
+.. autoclass:: fortnite_api.NewCosmetics
+    :members:
+
+.. autoclass:: fortnite_api.RenderImage
+    :members:
+
 .. autoclass:: fortnite_api.MaterialInstanceImages
     :members:
 
@@ -56,10 +65,7 @@ The API has many objects that represent different parts of the Fortnite API. Bel
 .. autoclass:: fortnite_api.MaterialInstance
     :members:
 
-.. autoclass:: fortnite_api.NewCosmetic
-    :members:
-
-.. autoclass:: fortnite_api.NewCosmetics 
+.. autoclass:: fortnite_api.NewDisplayAsset
     :members:
 
 .. autoclass:: fortnite_api.News
@@ -95,12 +101,6 @@ The API has many objects that represent different parts of the Fortnite API. Bel
 .. autoclass:: fortnite_api.ShopEntryColors
     :members:
 
-.. autoclass:: fortnite_api.ShopEntryRenderImage
-    :members:
-
-.. autoclass:: fortnite_api.ShopEntryNewDisplayAsset
-    :members:
-
 .. autoclass:: fortnite_api.ShopEntry 
     :members:
 
diff --git a/docs/migrating.rst b/docs/migrating.rst
index abd02f1..65b0b43 100644
--- a/docs/migrating.rst
+++ b/docs/migrating.rst
@@ -404,7 +404,7 @@ shop objects to the new shop objects is as follows:
     *   - ``ShopLayout``
         - :class:`fortnite_api.ShopEntryLayout`
     *   - ``ShopNewDisplayAsset``
-        - :class:`fortnite_api.ShopEntryNewDisplayAsset`
+        - :class:`fortnite_api.NewDisplayAsset`
     *   - ``ShopMaterialInstance``
         - Depreciated, moved to a more generic :class:`fortnite_api.MaterialInstance` class for the material instance endpoints. More on this later.
     *   - ``ShopMaterialInstanceImages``
@@ -898,7 +898,7 @@ work with the library and upgrade from Version 2. Every object not already menti
 
 - :class:`fortnite_api.Images`: Represents images returned from the API. This is used across all cosmetics objects through the :class:`fortnite_api.CosmeticImages` object, as well as in the :class:`fortnite_api.Banner` object.
 
-- :class:`fortnite_api.MaterialInstance`: Represents a material instance in Fortnite. If you do not know what a material instance is, check the documentation for the object in the library. This is used when fetching material instances manually, or sometimes given in the :class:`~fortnite_api.ShopEntryNewDisplayAsset` object from the shop.
+- :class:`fortnite_api.MaterialInstance`: Represents a material instance in Fortnite. If you do not know what a material instance is, check the documentation for the object in the library. This is used when fetching material instances manually, or sometimes given in the :class:`~fortnite_api.NewDisplayAsset` object from the shop.
 
 - :class:`fortnite_api.MaterialInstanceImages`: A special class that represents the images of a material instance. It is given from the :class:`~fortnite_api.MaterialInstance` class. 
 
diff --git a/fortnite_api/__init__.py b/fortnite_api/__init__.py
index d6de9af..6d595c9 100644
--- a/fortnite_api/__init__.py
+++ b/fortnite_api/__init__.py
@@ -39,8 +39,8 @@ from .errors import *
 from .flags import *
 from .images import *
 from .map import *
-from .material import *
 from .new import *
+from .new_display_asset import *
 from .news import *
 from .playlist import *
 from .shop import *
diff --git a/fortnite_api/client.py b/fortnite_api/client.py
index 451f566..ec2ff98 100644
--- a/fortnite_api/client.py
+++ b/fortnite_api/client.py
@@ -43,8 +43,8 @@ from .errors import BetaAccessNotEnabled, BetaUnknownException, MissingAPIKey
 from .flags import ResponseFlags
 from .http import HTTPClient, SyncHTTPClient
 from .map import Map
-from .material import MaterialInstance
 from .new import NewCosmetics
+from .new_display_asset import MaterialInstance, NewDisplayAsset
 from .news import GameModeNews, News
 from .playlist import Playlist
 from .proxies import TransformerListProxy
@@ -1095,6 +1095,56 @@ class Client:
 
     # BETA METHODS
 
+    @beta_method
+    async def beta_fetch_new_display_assets(self) -> List[NewDisplayAsset]:
+        """|coro|
+
+        Fetches all the new display assets available in Fortnite.
+
+        .. versionadded:: v3.1.0
+
+        .. note::
+
+            This is a beta method. This cannot be called unless :attr:`beta`
+            is set to ``True`` in the client.
+
+        .. warning::
+
+            At any time, for any reason, this method could break - either from local parsing errors or upstream API changes. Always be prepared for this. It is highly recommended
+            to wrap this method in a try/except block to catch any exceptions that may be raised.
+
+            .. code-block:: python3
+
+                try:
+                    assets = await client.beta_fetch_new_display_assets()
+                except fortnite_api.BetaUnknownException as exc:
+                    # Any exception raised from beta methods will be wrapped in BetaUnknownException,
+                    # get the root cause of the exception with exc.original.
+                    original = exc.original
+                    print(f"An unknown error occurred: {original}")
+                else:
+                    print(assets)
+
+        Returns
+        --------
+        List[:class:`fortnite_api.NewDisplayAsset`]
+            The fetched new display assets.
+
+        Raises
+        ------
+        BetaAccessNotEnabled
+            The client does not have beta access enabled through :attr:`~beta`.
+        BetaUnknownException
+            An unknown error occurred while fetching the new display assets. This could be due to
+            an issue with the API or the client. Any unknown errors raised will be wrapped in this exception.
+        """
+        data = await self.http.beta_get_new_display_assets()
+
+        return TransformerListProxy(
+            data,
+            lambda x: NewDisplayAsset(data=x, http=self.http),
+        )
+
     @beta_method
     async def beta_fetch_material_instances(self) -> List[MaterialInstance]:
         """|coro|
@@ -1566,6 +1616,16 @@ class SyncClient:
 
         raise ValueError("You must pass either a name or an account_id to fetch stats.")
 
+    @copy_doc(Client.beta_fetch_new_display_assets)
+    @beta_method
+    def beta_fetch_new_display_assets(self) -> List[NewDisplayAsset[SyncHTTPClient]]:
+        data = self.http.beta_get_new_display_assets()
+
+        return TransformerListProxy(
+            data,
+            lambda x: NewDisplayAsset(data=x, http=self.http),
+        )
+
     @copy_doc(Client.beta_fetch_material_instances)
     @beta_method
     def beta_fetch_material_instances(self) -> List[MaterialInstance[SyncHTTPClient]]:
diff --git a/fortnite_api/http.py b/fortnite_api/http.py
index ea60568..63dcc3d 100644
--- a/fortnite_api/http.py
+++ b/fortnite_api/http.py
@@ -344,6 +344,10 @@ class HTTPMixin(abc.ABC):
 
         return self.request(r, params=params)
 
+    def beta_get_new_display_assets(self):
+        r: Route = Route('GET', '/beta/newdisplayassets')
+        return self.request(r)
+
     def beta_get_material_instances(self):
         r: Route = Route('GET', '/beta/materialinstances')
         return self.request(r)
diff --git a/fortnite_api/material.py b/fortnite_api/new_display_asset.py
similarity index 67%
rename from fortnite_api/material.py
rename to fortnite_api/new_display_asset.py
index 1c95f69..b30a7c7 100644
--- a/fortnite_api/material.py
+++ b/fortnite_api/new_display_asset.py
@@ -24,7 +24,7 @@ SOFTWARE.
 
 from __future__ import annotations
 
-from typing import Any, Dict, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 
 from .abc import Hashable, ReconstructAble
 from .asset import Asset
@@ -33,12 +33,46 @@ from .http import HTTPClientT
 from .utils import get_with_fallback
 
 __all__: Tuple[str, ...] = (
+    "RenderImage",
     "MaterialInstanceImages",
     "MaterialInstanceColors",
     "MaterialInstance",
+    "NewDisplayAsset",
 )
 
 
+class RenderImage(ReconstructAble[Dict[str, Any], HTTPClientT]):
+    """
+    .. attributetable:: fortnite_api.RenderImage
+
+    Represents a render image for a shop entry. A render image is an image
+    used to visually represent a cosmetic item in the shop. This class inherits
+    from :class:`~fortnite_api.ReconstructAble`.
+
+    .. versionchanged:: v3.1.0
+        Renamed from ``ShopEntryRenderImage`` to a more generic ``RenderImage``
+        to reflect its usage across the API.
+
+    Attributes
+    ----------
+    product_tag: :class:`fortnite_api.ProductTag`
+        The product tag of the render image.
+    file_name: :class:`str`
+        The internal file name of the rendered image. Refers to the name within the game files and **not** the :attr`image`. An example of this is ``T-Featured-Pickaxes-SleepyTimePickaxe``.
+    image: :class:`fortnite_api.Asset`
+        The image of the render image.
+    """
+
+    __slots__: Tuple[str, ...] = ("product_tag", "file_name", "image")
+
+    def __init__(self, *, data: Dict[str, Any], http: HTTPClientT) -> None:
+        super().__init__(data=data, http=http)
+
+        self.product_tag: ProductTag = ProductTag._from_str(data["productTag"])
+        self.file_name: str = data["fileName"]
+        self.image: Asset[HTTPClientT] = Asset(url=data["image"], http=http)
+
+
 class MaterialInstanceImages(Dict[str, Asset[HTTPClientT]]):
     """
     .. attributetable:: fortnite_api.MaterialInstanceImages
@@ -50,6 +84,9 @@ class MaterialInstanceImages(Dict[str, Asset[HTTPClientT]]):
     This class inherits from :class:`dict`, and thus, supports all the operations that a normal
     dictionary does, such as indexing, iteration, etc.
 
+    .. versionchanged:: v3.1.0
+        Moved from the now-deleted ``fortnite_api.material`` to ``fortnite_api.new_display_asset``.
+
     Attributes
     ----------
     offer_image: Optional[:class:`fortnite_api.Asset`]
@@ -82,6 +119,9 @@ class MaterialInstanceColors(Dict[str, str]):
 
     This class inherits from :class:`dict`, and thus, supports all the operations that a normal dictionary does, such as indexing, iteration, etc.
 
+    .. versionchanged:: v3.1.0
+        Moved from the now-deleted ``fortnite_api.material`` to ``fortnite_api.new_display_asset``.
+
     Attributes
     ----------
     background_color_a: Optional[:class:`str`]
@@ -120,6 +160,9 @@ class MaterialInstance(Hashable, ReconstructAble[Dict[str, Any], HTTPClientT]):
 
     This class inherits from :class:`~fortnite_api.Hashable` and :class:`~fortnite_api.ReconstructAble`.
 
+    .. versionchanged:: v3.1.0
+        Moved from the now-deleted ``fortnite_api.material`` to ``fortnite_api.new_display_asset``.
+
     Attributes
     ----------
     id: :class:`str`
@@ -169,3 +212,42 @@ class MaterialInstance(Hashable, ReconstructAble[Dict[str, Any], HTTPClientT]):
         self.scalings: Dict[str, Any] = get_with_fallback(data, "scalings", dict)
 
         self.flags: Dict[str, Any] = get_with_fallback(data, "flags", dict)  # This is always None at this time.
+
+
+class NewDisplayAsset(Hashable, ReconstructAble[Dict[str, Any], HTTPClientT]):
+    """
+    .. attributetable:: fortnite_api.NewDisplayAsset
+
+    Represents a new display asset for a shop entry. A display asset is an asset that is
+    used to visually represent a cosmetic item in the shop. This class inherits
+    from :class:`~fortnite_api.ReconstructAble` and :class:`~fortnite_api.Hashable`.
+
+    Attributes
+    ----------
+    id: :class:`str`
+        The ID of the display asset.
+    cosmetic_id: Optional[:class:`str`]
+        The ID of the cosmetic item associated with the display asset, if any.
+    material_instances: List[:class:`fortnite_api.MaterialInstance`]
+        A list of material instances used by the display asset.
+    render_images: List[:class:`fortnite_api.RenderImage`]
+        A list of render images used by the display asset.
+
+    .. versionchanged:: v3.1.0
+        Renamed from ``ShopEntryNewDisplayAsset`` to a more generic ``NewDisplayAsset``
+        to better reflect its usage across the API.
+    """
+
+    __slots__: Tuple[str, ...] = ("id", "cosmetic_id", "material_instances", "render_images")
+
+    def __init__(self, *, data: Dict[str, Any], http: HTTPClientT) -> None:
+        super().__init__(data=data, http=http)
+
+        self.id: str = data["id"]
+        self.cosmetic_id: Optional[str] = data.get("cosmeticId")
+        self.material_instances: List[MaterialInstance[HTTPClientT]] = [
+            MaterialInstance(data=instance, http=http) for instance in get_with_fallback(data, "materialInstances", list)
+        ]
+        self.render_images: List[RenderImage[HTTPClientT]] = [
+            RenderImage(data=instance, http=http) for instance in get_with_fallback(data, "renderImages", list)
+        ]
diff --git a/fortnite_api/shop.py b/fortnite_api/shop.py
index 7fe2e29..d05ed77 100644
--- a/fortnite_api/shop.py
+++ b/fortnite_api/shop.py
@@ -33,9 +33,9 @@ from typing_extensions import Self
 from .abc import Hashable, ReconstructAble
 from .asset import Asset
 from .cosmetics import CosmeticBr, CosmeticCar, CosmeticInstrument, CosmeticLegoKit, CosmeticTrack
-from .enums import BannerIntensity, ProductTag
+from .enums import BannerIntensity
 from .http import HTTPClientT
-from .material import MaterialInstance
+from .new_display_asset import NewDisplayAsset
 from .proxies import TransformerListProxy
 from .utils import get_with_fallback, parse_time
 
@@ -45,8 +45,6 @@ __all__: Tuple[str, ...] = (
     "ShopEntryBanner",
     "ShopEntryLayout",
     "ShopEntryColors",
-    "ShopEntryRenderImage",
-    "ShopEntryNewDisplayAsset",
     "ShopEntry",
     "Shop",
     "TileSize",
@@ -309,69 +307,6 @@ class ShopEntryColors(ReconstructAble[Dict[str, Any], HTTPClientT]):
         self.text_background_color: Optional[str] = data.get('textBackgroundColor')
 
 
-class ShopEntryRenderImage(ReconstructAble[Dict[str, Any], HTTPClientT]):
-    """
-    .. attributetable:: fortnite_api.ShopEntryRenderImage
-
-    Represents a render image for a shop entry. A render image is an image
-    used to visually represent a cosmetic item in the shop. This class inherits
-    from :class:`~fortnite_api.ReconstructAble`.
-
-    Attributes
-    ----------
-    product_tag: :class:`fortnite_api.ProductTag`
-        The product tag of the render image.
-    file_name: :class:`str`
-        The internal file name of the rendered image. Refers to the name within the game files and **not** the :attr`image`. An example of this is ``T-Featured-Pickaxes-SleepyTimePickaxe``.
-    image: :class:`fortnite_api.Asset`
-        The image of the render image.
-    """
-
-    __slots__: Tuple[str, ...] = ("product_tag", "file_name", "image")
-
-    def __init__(self, *, data: Dict[str, Any], http: HTTPClientT) -> None:
-        super().__init__(data=data, http=http)
-
-        self.product_tag: ProductTag = ProductTag._from_str(data["productTag"])
-        self.file_name: str = data["fileName"]
-        self.image: Asset[HTTPClientT] = Asset(url=data["image"], http=http)
-
-
-class ShopEntryNewDisplayAsset(Hashable, ReconstructAble[Dict[str, Any], HTTPClientT]):
-    """
-    .. attributetable:: fortnite_api.ShopEntryNewDisplayAsset
-
-    Represents a new display asset for a shop entry. A display asset is an asset that is
-    used to visually represent a cosmetic item in the shop. This class inherits
-    from :class:`~fortnite_api.ReconstructAble`.
-
-    Attributes
-    ----------
-    id: :class:`str`
-        The ID of the display asset.
-    cosmetic_id: Optional[:class:`str`]
-        The ID of the cosmetic item associated with the display asset, if any.
-    material_instances: List[:class:`fortnite_api.MaterialInstance`]
-        A list of material instances used by the display asset.
-    render_images: List[:class:`fortnite_api.ShopEntryRenderImage`]
-        A list of render images used by the display asset.
-    """
-
-    __slots__: Tuple[str, ...] = ("id", "cosmetic_id", "material_instances", "render_images")
-
-    def __init__(self, *, data: Dict[str, Any], http: HTTPClientT) -> None:
-        super().__init__(data=data, http=http)
-
-        self.id: str = data["id"]
-        self.cosmetic_id: Optional[str] = data.get("cosmeticId")
-        self.material_instances: List[MaterialInstance[HTTPClientT]] = [
-            MaterialInstance(data=instance, http=http) for instance in get_with_fallback(data, "materialInstances", list)
-        ]
-        self.render_images: List[ShopEntryRenderImage[HTTPClientT]] = [
-            ShopEntryRenderImage(data=instance, http=http) for instance in get_with_fallback(data, "renderImages", list)
-        ]
-
-
 class ShopEntry(ReconstructAble[Dict[str, Any], HTTPClientT]):
     """
     .. attributetable:: fortnite_api.ShopEntry
@@ -443,7 +378,7 @@ class ShopEntry(ReconstructAble[Dict[str, Any], HTTPClientT]):
         The tile size of this entry.
     new_display_asset_path: Optional[:class:`str`]
         The new display asset path of this entry.
-    new_display_asset: :class:`fortnite_api.ShopEntryNewDisplayAsset`
+    new_display_asset: :class:`fortnite_api.NewDisplayAsset`
         The new display asset of this entry.
     colors: :class:`fortnite_api.ShopEntryColors`
         The colors of this entry.
@@ -523,8 +458,8 @@ class ShopEntry(ReconstructAble[Dict[str, Any], HTTPClientT]):
         self.new_display_asset_path: Optional[str] = data.get("newDisplayAssetPath")
 
         _new_display_asset = data.get("newDisplayAsset")
-        self.new_display_asset: Optional[ShopEntryNewDisplayAsset[HTTPClientT]] = (
-            _new_display_asset and ShopEntryNewDisplayAsset(data=data["newDisplayAsset"], http=http)
+        self.new_display_asset: Optional[NewDisplayAsset[HTTPClientT]] = _new_display_asset and NewDisplayAsset(
+            data=data["newDisplayAsset"], http=http
         )
 
         _colors = data.get("colors")


Test patch:
diff --git a/tests/test_async_methods.py b/tests/test_async_methods.py
index d1345bc..9782845 100644
--- a/tests/test_async_methods.py
+++ b/tests/test_async_methods.py
@@ -360,6 +360,34 @@ async def test_async_fetch_playlist_by_id(api_key: str):
     _test_playlist(playlist)
 
 
+@pytest.mark.asyncio
+async def test_async_beta_fetch_new_display_assets(api_key: str):
+
+    # Ensure you cannot call this without beta=True
+    with pytest.raises(fn_api.BetaAccessNotEnabled):
+        await fn_api.Client().beta_fetch_new_display_assets()
+
+    async with fn_api.Client(beta=True, api_key=api_key) as client:
+        new_display_assets = await client.beta_fetch_new_display_assets()
+
+    for new_display_asset in new_display_assets:
+        assert isinstance(new_display_asset, fn_api.NewDisplayAsset)
+
+        assert new_display_asset.id
+
+        for material_instance in new_display_asset.material_instances:
+            assert isinstance(material_instance, fn_api.MaterialInstance)
+
+        for render_image in new_display_asset.render_images:
+            assert isinstance(render_image, fn_api.RenderImage)
+
+            assert isinstance(render_image.product_tag, fn_api.ProductTag)
+            assert render_image.file_name
+            assert isinstance(render_image.image, fn_api.Asset)
+
+        assert new_display_asset == new_display_asset
+
+
 @pytest.mark.asyncio
 async def test_async_beta_fetch_material_instances(api_key: str):
 
@@ -452,7 +480,7 @@ async def test_async_fetch_shop(api_key: str):
 
         new_display_asset = entry.new_display_asset
         if new_display_asset:
-            assert isinstance(new_display_asset, fn_api.ShopEntryNewDisplayAsset)
+            assert isinstance(new_display_asset, fn_api.NewDisplayAsset)
             assert new_display_asset.id
 
             for material_instance in new_display_asset.material_instances:
diff --git a/tests/test_beta.py b/tests/test_beta.py
index 30b90fc..7a34cc7 100644
--- a/tests/test_beta.py
+++ b/tests/test_beta.py
@@ -33,7 +33,7 @@ from fortnite_api.client import beta_method
 def test_sync_cannot_call_beta_method():
     client = fortnite_api.SyncClient(beta=False)
     with client, pytest.raises(fortnite_api.BetaAccessNotEnabled):
-        client.beta_fetch_material_instances()
+        client.beta_fetch_new_display_assets()
 
 
 @pytest.mark.asyncio
@@ -41,7 +41,7 @@ async def test_async_cannot_call_beta_method():
     client = fortnite_api.Client(beta=False)
     with pytest.raises(fortnite_api.BetaAccessNotEnabled):
         async with client:
-            await client.beta_fetch_material_instances()
+            await client.beta_fetch_new_display_assets()
 
 
 # A mock of the SyncClient beta function that raises an error
diff --git a/tests/test_sync_methods.py b/tests/test_sync_methods.py
index b21f673..c6e6de1 100644
--- a/tests/test_sync_methods.py
+++ b/tests/test_sync_methods.py
@@ -295,6 +295,33 @@ def test_sync_fetch_playlist_by_id(api_key: str):
     _test_playlist(playlist)
 
 
+def test_sync_beta_fetch_new_display_assets(api_key: str):
+
+    # Ensure you cannot call this without beta=True
+    with pytest.raises(fn_api.BetaAccessNotEnabled):
+        fn_api.SyncClient().beta_fetch_new_display_assets()
+
+    with fn_api.SyncClient(beta=True, api_key=api_key) as client:
+        new_display_assets = client.beta_fetch_new_display_assets()
+
+    for new_display_asset in new_display_assets:
+        assert isinstance(new_display_asset, fn_api.NewDisplayAsset)
+
+        assert new_display_asset.id
+
+        for material_instance in new_display_asset.material_instances:
+            assert isinstance(material_instance, fn_api.MaterialInstance)
+
+        for render_image in new_display_asset.render_images:
+            assert isinstance(render_image, fn_api.RenderImage)
+
+            assert isinstance(render_image.product_tag, fn_api.ProductTag)
+            assert render_image.file_name
+            assert isinstance(render_image.image, fn_api.Asset)
+
+        assert new_display_asset == new_display_asset
+
+
 def test_sync_beta_fetch_material_instances(api_key: str):
     # Ensure you cannot call this without beta=True
     with pytest.raises(fn_api.BetaAccessNotEnabled):
@@ -378,7 +405,7 @@ def test_sync_fetch_shop(api_key: str):
 
         new_display_asset = entry.new_display_asset
         if new_display_asset:
-            assert isinstance(new_display_asset, fn_api.ShopEntryNewDisplayAsset)
+            assert isinstance(new_display_asset, fn_api.NewDisplayAsset)
             assert new_display_asset.id
 
             for material_instance in new_display_asset.material_instances: