Skip to content

Async/Sync Tests Rewrite #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 41 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ab17fc2
Create test_methods.py
trevorflahardy Oct 15, 2024
11e45c3
Correct Concatenate error
trevorflahardy Oct 15, 2024
06af63e
Update for consistency with "is"
trevorflahardy Oct 23, 2024
7414b06
Fix ReconstructAble.from_dict typing
trevorflahardy Oct 23, 2024
540b688
Correct type narrowing for async and sync reconstructable checks
trevorflahardy Oct 23, 2024
a5be0ae
Black isort for recent changes
trevorflahardy Oct 23, 2024
d257a80
Start mock python pytests
trevorflahardy Nov 22, 2024
ae25d17
Upgrade imports for Python 3.9
Luc1412 Nov 24, 2024
6abfb8b
Update project for pytest logging output, if needed for some tests
trevorflahardy Dec 13, 2024
3fd0541
Update gitignore for build files in newer Python versions
trevorflahardy Dec 13, 2024
6606092
Start moving test methods
trevorflahardy Dec 13, 2024
944f1a0
Add README for test maintainers.
trevorflahardy Dec 13, 2024
d9eb490
Remove now unused file
trevorflahardy Dec 13, 2024
02c50fa
Move internal cosmetic helpers to new file
trevorflahardy Dec 13, 2024
f21c97a
Migrate cosmetic test functions, update the cosmetic handling of test…
trevorflahardy Dec 13, 2024
c0e46ca
Black isort
trevorflahardy Dec 13, 2024
2cdd1d3
Add clarification for test_methods
trevorflahardy Dec 13, 2024
cbf3c59
Rename all test_async -> test
trevorflahardy Dec 13, 2024
be9652e
Updates to tests
trevorflahardy Dec 13, 2024
c44e319
Refactor how similar tests behave for better maintainability
trevorflahardy Dec 13, 2024
3ce6882
test_cosmetic_lego_kits -> test_cosmetic_lego_kit
trevorflahardy Dec 13, 2024
f704111
Black isort
trevorflahardy Dec 13, 2024
925bb2b
Update some terminology for error handling and documentation
trevorflahardy Dec 13, 2024
da124eb
Add response flags to cosmetic tests
trevorflahardy Dec 13, 2024
d38a187
Fix import
trevorflahardy Dec 15, 2024
49c1a0e
Fix import
trevorflahardy Dec 15, 2024
03ed4f3
Remove log
trevorflahardy Dec 15, 2024
5f89f5e
Fixes to tests
trevorflahardy Dec 15, 2024
dbfdd64
Revert some changes from master manually
trevorflahardy Dec 15, 2024
a4f24fe
Add back annotation for len return
trevorflahardy Dec 21, 2024
6dbd588
Remove duplicated `async with` statement causing test to fail
trevorflahardy Dec 21, 2024
3500aba
Rename test_x to validate_x so pyright doesn't pickup validation func…
trevorflahardy Dec 21, 2024
72acb8c
Rename `_test` to `_validate` to be more consistent with other test v…
trevorflahardy Dec 21, 2024
6887549
Remove "synchronize" test condition to remove duplicate tests running.
trevorflahardy Dec 21, 2024
53d1c09
Remove unneeded reconstruction test, as the HybridClient now covers t…
trevorflahardy Dec 21, 2024
da4a0ab
Remove reference to deleted file
trevorflahardy Dec 21, 2024
796ed4a
Cleanup wording in README.md
trevorflahardy Dec 21, 2024
1d263d7
Add missing filename in README.md
trevorflahardy Dec 21, 2024
b074e91
black and isort
Luc1412 Dec 21, 2024
3ee8cfd
delete test_sync_methods.py
Luc1412 Dec 21, 2024
e8221dd
Update tests/README.md
trevorflahardy Jan 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name: Docs
on:
push:
pull_request:
types: [ opened, reopened, synchronize ]
types: [opened, reopened]
workflow_dispatch:

jobs:
Expand All @@ -31,4 +31,4 @@ jobs:
- name: Build Documentation
run: |
cd docs
sphinx-build -b html -j auto -a -n -T -W --keep-going . _build/html
sphinx-build -b html -j auto -a -n -T -W --keep-going . _build/html
2 changes: 1 addition & 1 deletion .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: PyPI Release

on:
release:
types: [ published ]
types: [published]
workflow_dispatch:

jobs:
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ name: Pytests
on:
push:
pull_request:
types: [ opened, reopened, synchronize ]
types: [opened, reopened]
workflow_dispatch:

jobs:
Expand Down Expand Up @@ -82,4 +82,4 @@ jobs:
pip install -e .[dev]

- name: Run Isort Check
run: isort --check --diff fortnite_api
run: isort --check --diff fortnite_api
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,7 @@ fabric.properties

# Development Test Files
_.py
.env
.env

# Build files
/build/*
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ You can generate an API key on <https://dash.fortnite-api.com/account> by loggin

```python
import asyncio
import fortnite_api
import fortnite_api

async def main() -> None:
async with fortnite_api.Client() as client:
all_cosmetics: fortnite_api.CosmeticsAll = await client.fetch_cosmetics_all()

for br_cosmetic in all_cosmetics.br:
print(br_cosmetic.name)
print(br_cosmetic.name)

if __name__ == "__main__":
asyncio.run(main())
Expand Down
10 changes: 5 additions & 5 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ To install the Fortnite-API Python library, you can use pip. Run the following c
To install the latest development version of the library, you can use the following command:

.. code-block:: bash

git clone https://github.com/Fortnite-API/py-wrapper
cd py-wrapper
python3 -m pip install .
Expand All @@ -36,7 +36,7 @@ Optional Dependencies
- `speed`: An optional dependency that installs `orjson <https://github.com/ijl/orjson>`_ for faster JSON serialization and deserialization.

.. code-block:: bash

# Linux/macOS
python3 -m pip install fortnite-api[speed]

Expand Down Expand Up @@ -68,9 +68,9 @@ You can generate an API key on `the dashboard <https://dash.fortnite-api.com/acc

View Documentation
------------------
The entirety of the public API is documented here. If you're looking for a specific method, class, or module, the search bar at the top right is your friend.
The entirety of the public API is documented here. If you're looking for a specific method, class, or module, the search bar at the top right is your friend.

If you're not sure where to start, check out the :class:`fortnite_api.Client` class for a list of all available methods you can use to interact with the API.
If you're not sure where to start, check out the :class:`fortnite_api.Client` class for a list of all available methods you can use to interact with the API.

.. toctree::
:maxdepth: 3
Expand All @@ -94,7 +94,7 @@ The changelog contains a list of all changes made to the Fortnite-API Python lib

.. toctree::
:maxdepth: 3

changelog


Expand Down
33 changes: 18 additions & 15 deletions fortnite_api/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,14 @@
from __future__ import annotations

import copy
from typing import TYPE_CHECKING, Generic, TypeVar, Union

from typing_extensions import Self
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, overload

from .http import HTTPClient, HTTPClientT, SyncHTTPClient

DictT = TypeVar('DictT', bound='Mapping[Any, Any]')

if TYPE_CHECKING:
from collections.abc import Mapping
from typing import Any

from .client import Client, SyncClient

Expand Down Expand Up @@ -117,8 +114,20 @@ def __init__(self, *, data: DictT, http: HTTPClientT) -> None:
# method is overloaded to allow for both the async and sync clients to be passed, whilst
# still keeping the correct HTTPClient type.

@overload
@classmethod
def from_dict(
cls: type[ReconstructAble[Any, SyncHTTPClient]], data: DictT, *, client: SyncClient
) -> ReconstructAble[DictT, SyncHTTPClient]: ...

@overload
@classmethod
def from_dict(
cls: type[ReconstructAble[Any, HTTPClient]], data: DictT, *, client: Client
) -> ReconstructAble[DictT, HTTPClient]: ...

@classmethod
def from_dict(cls: type[Self], data: DictT, *, client: Union[Client, SyncClient]) -> Self:
def from_dict(cls, data: DictT, *, client: Union[Client, SyncClient]) -> Any:
"""Reconstructs this class from a raw dictionary object. This is useful for when you
store the raw data and want to reconstruct the object later on.

Expand All @@ -129,16 +138,10 @@ def from_dict(cls: type[Self], data: DictT, *, client: Union[Client, SyncClient]
client: Union[:class:`fortnite_api.Client`, :class:`fortnite_api.SyncClient`]
The currently used client to reconstruct the object with. Can either be a sync or async client.
"""
if isinstance(client.http, SyncHTTPClient):
# Whenever the client is a SyncClient, we can safely assume that the http
# attribute is a SyncHTTPClient, as this is the only HTTPClientT possible.
sync_http: SyncHTTPClient = client.http
return cls(data=data, http=sync_http) # type: ignore # Pyright cannot infer the type of cls
else:
# Whenever the client is a Client, we can safely assume that the http
# attribute is a HTTPClient, as this is the only HTTPClientT possible.
http: HTTPClient = client.http
return cls(data=data, http=http) # type: ignore # Pyright cannot infer the type of cls
# Even if we did an instance check here, Pyright cannot understand the narrowing of "http"
# from the "client" parameter. We must ignore this error.
http: HTTPClientT = client.http # type: ignore
return cls(data=data, http=http)

def to_dict(self) -> DictT:
"""Turns this object into a raw dictionary object. This is useful for when you
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ Documentation = "https://fortnite-api.readthedocs.io/en/rewrite/"
asyncio_mode = "strict"
testpaths = ["tests"]
addopts = "--import-mode=importlib"
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"

# Black formatting

Expand Down
59 changes: 59 additions & 0 deletions tests/README.md
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nice to directly embed links to those files within the readme for easy access.

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Fortnite API Library Tests

This library makes a large effort to ensure that the responses given from the API are stably transformed to their
respective Python objects. Every client, both in the `Client` and `SyncClient` classes are tested. This file
outlines how the tests are laid such that all these edge cases are handled.

## Generic Library Tests

Many tests in the main `/tests` directory are generic-related object-related tests. These ensure basic functionality surrounding how the more-complex objects of the library are constructed and function.

| Test File | Purpose and Logic |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `test_account.py` | Ensures that an `Account` object is created properly and its dunder methods work as expected. |
| `test_aes.py` | Ensures that `Aes` object initializes properly by checking known dynamic keys and hashes. |
| `test_asset.py` | Ensures that the rules regulating `Asset` resizing are correct and that the asset reading functions function correctly. |
| `test_beta.py` | Ensures that a user with the `beta` flag disabled on a `Client` cannot call beta methods. This validates that the beta flag decorator works as expected. |
| `test_proxy.py` | Ensures that the `TransformerListProxy` class initializes properly, transforms to expected objects as needed, and has the same interface as a typical `py.List` would. |
| `test_ratelimits.py` | Ensures that the library's handling of rate limits is correct, and related exceptions are raised as expected. |
| `test_repr.py` | The library uses a dynamic decorator to create the `__repr__` dunder by taking advantage of the `__slots__` on a class. This test ensures that the dynamic function works as expected. |
| `test_methods.py` | The handling of all the functions on the `Client` and `SyncClient` class. See Edge Cases below for more information. |

### Edge Case Library Tests

Due to the library's complexity, especially considering that it fully supports both `async` and `sync` functionality,
many edge cases are tested. Mainly, these tests are related to the `Client` and `SyncClient` classes, and the methods
defined on them.

#### Definition and Tests for the Hybrid Client

##### Test Client Hybrid: `test_client_hybrid.py`

The tests define a custom `ClientHybrid` class (in `./client/test_client_hybrid.py`). This class wraps a `Client` to act as an intermediatory between a requested API call and the actual method. When an API call is requested, the `ClientHybrid` will call **both** the async `Client` version and the `SyncClient` version of the method. The results are then compared to ensure that they are the same.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for ./ within the brackets?


Thus, all tests that make API calls will import and use the `ClientHybrid`.

As an example, consider the user requesting to call `fetch_aes()` using the `ClientHybrid`:

1. The `ClientHybrid` class is initialized as a context manager, the same as you would with a `Client`.
2. The `fetch_aes()` method is called on the `ClientHybrid`.
3. The sync method of `fetch_aes()` is called on an internally held `SyncClient` class.
4. The async method of `fetch_aes()` is called on the `Client` itself.
5. The result, if reconstructable or comparable, is checked to ensure that both returned objects are the same.
6. The result of the async method call is returned as the final value.

This approach, although loop blocking in nature, ensures that the results from both the `Client` and `SyncClient` are the same.

##### Test Client: `test_client.py`

The tests defined here ensure that the client's behavior surrounding initialization work as expected. This is, but is not limited to, context manager use, custom passed HTTP session management, etc.

#### Tests for the Methods on the Client: `test_methods.py`

Every method, except for those defined in `test_stats.py` and `cosmetics/*.py` (more on this directory after) on the `Client` is tested here. This uses the `ClientHybrid`, as described above.

This logic has been separated out of the conventional cosmetic tests due to the nature of the stats endpoints themselves. The `Client` must have API key while using them, unlike any other endpoint, and have been clustered together accordingly.

#### Cosmetic Tests: `/cosmetics/*.py`

A majority of the definitions in this library relate to the cosmetics of Fortnite. Thus, the tests for them are inherently large. To combat this, and to future proof the readability and maintainability of the library, these tests have been separated from others of the `Client` to `cosmetics/test_cosmetic_functions.py` and the associated internal helper functions to `cosmetics/cosmetic_utils.py`.
151 changes: 151 additions & 0 deletions tests/client/test_client_hybrid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
MIT License

Copyright (c) 2019-present Luc1412

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""

from __future__ import annotations

import inspect
import logging
from collections.abc import Callable, Coroutine
from typing import TYPE_CHECKING, Any, Generic, TypeVar

import pytest
import requests
from typing_extensions import Concatenate, ParamSpec, TypeAlias

import fortnite_api
from fortnite_api import ReconstructAble

P = ParamSpec('P')
T = TypeVar('T')

if TYPE_CHECKING:
Client: TypeAlias = fortnite_api.Client
SyncClient = fortnite_api.SyncClient

CoroFunc = Callable[P, Coroutine[Any, Any, T]]

log = logging.getLogger(__name__)


class HybridMethodProxy(Generic[P, T]):
def __init__(
self,
hybrid_client: ClientHybrid,
sync_client: SyncClient,
async_method: CoroFunc[Concatenate[Client, P], T],
sync_method: Callable[Concatenate[SyncClient, P], T],
) -> None:
self.__hybrid_client = hybrid_client
self.__sync_client = sync_client

self.__async_method = async_method
self.__sync_method = sync_method

@property
def __name__(self) -> str:
return self.__async_method.__name__

def _validate_results(self, async_res: T, sync_res: T) -> None:
assert type(async_res) is type(sync_res), f"Expected {type(async_res)}, got {type(sync_res)}"

if isinstance(async_res, fortnite_api.Hashable):
assert isinstance(sync_res, fortnite_api.Hashable)
assert async_res == sync_res
log.debug('Hashable comparison passed for method %s.', self.__async_method.__name__)

if isinstance(async_res, fortnite_api.ReconstructAble):
assert isinstance(sync_res, fortnite_api.ReconstructAble)

sync_res_narrowed: ReconstructAble[Any, fortnite_api.SyncHTTPClient] = sync_res
async_res_narrowed: ReconstructAble[Any, fortnite_api.HTTPClient] = async_res

async_raw_data = sync_res_narrowed.to_dict()
sync_raw_data = sync_res_narrowed.to_dict()
assert async_raw_data == sync_raw_data
log.debug('Raw data equality passed for method %s', self.__async_method.__name__)

async_reconstructed = type(async_res_narrowed).from_dict(async_raw_data, client=self.__hybrid_client)
sync_reconstructed = type(sync_res_narrowed).from_dict(sync_raw_data, client=self.__sync_client)

assert isinstance(async_reconstructed, type(sync_reconstructed))
assert type(async_reconstructed) is type(async_res_narrowed)
assert type(sync_reconstructed) is type(sync_res_narrowed)
log.debug('Reconstructed data equality passed for method %s', self.__async_method.__name__)

async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
# Call the sync method first
sync_result = self.__sync_method(self.__sync_client, *args, **kwargs)

# Call the async method
async_result = await self.__async_method(self.__hybrid_client, *args, **kwargs)

log.debug('Validating results for %s', self.__async_method.__name__)
self._validate_results(async_result, sync_result)
return async_result


class ClientHybrid(fortnite_api.Client):
"""Denotes a "client-hybrid" that calls both a async and sync
client when a method is called.

Pytest tests are not called in parallel, so although this is a
blocking operation it will not affect the overall performance of
the tests.
"""

def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

kwargs.pop('session', None)
session = requests.Session()
self.__sync_client: fortnite_api.SyncClient = fortnite_api.SyncClient(*args, session=session, **kwargs)
self.__inject_hybrid_methods()

def __inject_hybrid_methods(self) -> None:
# Walks through all the public coroutine methods in this class. If it finds one,
# it will mark it as a hybrid proxy method with it and its sync counterpart.
for key, value in fortnite_api.Client.__dict__.items():
if inspect.iscoroutinefunction(value):
sync_value = getattr(fortnite_api.SyncClient, key, None)
if sync_value is not None and inspect.isfunction(sync_value):
setattr(self, key, HybridMethodProxy(self, self.__sync_client, value, sync_value))

async def __aexit__(self, *args: Any) -> None:
# We need to ensure that the sync client is also closed
self.__sync_client.__exit__(*args)
return await super().__aexit__(*args)


@pytest.mark.asyncio
async def test_hybrid_client():
hybrid_client = ClientHybrid()

# Walk through all coroutines in the normal client - ensure that
# every coro on the normal is a proxy method on the hybrid client.
for key, value in fortnite_api.Client.__dict__.items():
if inspect.iscoroutinefunction(value) and not key.startswith('_'):
assert hasattr(hybrid_client, key)

method = getattr(hybrid_client, key)
assert isinstance(method, HybridMethodProxy)
Loading
Loading