Skip to content

Commit 8a58c78

Browse files
Async/Sync Tests Rewrite (#39)
* Create test_methods.py Start working on ClientHybrid class. Denotes a type of client that calls both the sync and async versions of a client's fetch_x methods. This is so that, when testing, you don't need two tests for each client type. The guarantee is that the return types between the two clients are the same, so the subsequent tests must also be valid for both of them. This is backed up by a `HybridMethodProxy` class which basically acts as the actual caller returned by the ClientHyrbid to do some validation. * Correct Concatenate error * Update for consistency with "is" * Fix ReconstructAble.from_dict typing * Correct type narrowing for async and sync reconstructable checks * Black isort for recent changes * Start mock python pytests * Upgrade imports for Python 3.9 * Update project for pytest logging output, if needed for some tests * Update gitignore for build files in newer Python versions * Start moving test methods * Add README for test maintainers. * Remove now unused file * Move internal cosmetic helpers to new file * Migrate cosmetic test functions, update the cosmetic handling of test_fetch_shop to use correct test functions * Black isort * Add clarification for test_methods * Rename all test_async -> test * Updates to tests * Refactor how similar tests behave for better maintainability * test_cosmetic_lego_kits -> test_cosmetic_lego_kit * Black isort * Update some terminology for error handling and documentation * Add response flags to cosmetic tests * Fix import * Fix import * Remove log * Fixes to tests * Revert some changes from master manually * Add back annotation for len return * Remove duplicated `async with` statement causing test to fail * Rename test_x to validate_x so pyright doesn't pickup validation functions for known types. * Rename `_test` to `_validate` to be more consistent with other test validation functions. Kept them as `_` to denote how they are not exported. * Remove "synchronize" test condition to remove duplicate tests running. * Remove unneeded reconstruction test, as the HybridClient now covers this for every API call. * Remove reference to deleted file * Cleanup wording in README.md * Add missing filename in README.md * black and isort * delete test_sync_methods.py * Update tests/README.md Co-authored-by: Lucas Hardt <[email protected]> * Implement file links into test docs --------- Co-authored-by: Trevor Flahardy <[email protected]>
1 parent b533395 commit 8a58c78

19 files changed

+1066
-1289
lines changed

.github/workflows/docs.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ name: Docs
44
on:
55
push:
66
pull_request:
7-
types: [ opened, reopened, synchronize ]
7+
types: [opened, reopened]
88
workflow_dispatch:
99

1010
permissions:
@@ -34,4 +34,4 @@ jobs:
3434
- name: Build Documentation
3535
run: |
3636
cd docs
37-
sphinx-build -b html -j auto -a -n -T -W --keep-going . _build/html
37+
sphinx-build -b html -j auto -a -n -T -W --keep-going . _build/html

.github/workflows/pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: PyPI Release
22

33
on:
44
release:
5-
types: [ published ]
5+
types: [published]
66
workflow_dispatch:
77

88
permissions:

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ name: Pytests
66
on:
77
push:
88
pull_request:
9-
types: [ opened, reopened, synchronize ]
9+
types: [opened, reopened]
1010
workflow_dispatch:
1111

1212
permissions:

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,7 @@ fabric.properties
8787

8888
# Development Test Files
8989
_.py
90-
.env
90+
.env
91+
92+
# Build files
93+
/build/*

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,14 @@ You can generate an API key on <https://dash.fortnite-api.com/account> by loggin
7070

7171
```python
7272
import asyncio
73-
import fortnite_api
73+
import fortnite_api
7474

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

7979
for br_cosmetic in all_cosmetics.br:
80-
print(br_cosmetic.name)
80+
print(br_cosmetic.name)
8181

8282
if __name__ == "__main__":
8383
asyncio.run(main())

docs/index.rst

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ To install the Fortnite-API Python library, you can use pip. Run the following c
2525
To install the latest development version of the library, you can use the following command:
2626

2727
.. code-block:: bash
28-
28+
2929
git clone https://github.com/Fortnite-API/py-wrapper
3030
cd py-wrapper
3131
python3 -m pip install .
@@ -36,7 +36,7 @@ Optional Dependencies
3636
- `speed`: An optional dependency that installs `orjson <https://github.com/ijl/orjson>`_ for faster JSON serialization and deserialization.
3737

3838
.. code-block:: bash
39-
39+
4040
# Linux/macOS
4141
python3 -m pip install fortnite-api[speed]
4242
@@ -68,9 +68,9 @@ You can generate an API key on `the dashboard <https://dash.fortnite-api.com/acc
6868

6969
View Documentation
7070
------------------
71-
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.
71+
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.
7272

73-
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.
73+
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.
7474

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

9595
.. toctree::
9696
:maxdepth: 3
97-
97+
9898
changelog
9999

100100

fortnite_api/abc.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,14 @@
2525
from __future__ import annotations
2626

2727
import copy
28-
from typing import TYPE_CHECKING, Generic, TypeVar
29-
30-
from typing_extensions import Self
28+
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, overload
3129

3230
from .http import HTTPClient, HTTPClientT, SyncHTTPClient
3331

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

3634
if TYPE_CHECKING:
3735
from collections.abc import Mapping
38-
from typing import Any
3936

4037
from .client import Client, SyncClient
4138

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

117+
@overload
118+
@classmethod
119+
def from_dict(
120+
cls: type[ReconstructAble[Any, SyncHTTPClient]], data: DictT, *, client: SyncClient
121+
) -> ReconstructAble[DictT, SyncHTTPClient]: ...
122+
123+
@overload
124+
@classmethod
125+
def from_dict(
126+
cls: type[ReconstructAble[Any, HTTPClient]], data: DictT, *, client: Client
127+
) -> ReconstructAble[DictT, HTTPClient]: ...
128+
120129
@classmethod
121-
def from_dict(cls: type[Self], data: DictT, *, client: Client | SyncClient) -> Self:
130+
def from_dict(cls, data: DictT, *, client: Client | SyncClient) -> Any:
122131
"""Reconstructs this class from a raw dictionary object. This is useful for when you
123132
store the raw data and want to reconstruct the object later on.
124133
@@ -129,16 +138,10 @@ def from_dict(cls: type[Self], data: DictT, *, client: Client | SyncClient) -> S
129138
client: Union[:class:`fortnite_api.Client`, :class:`fortnite_api.SyncClient`]
130139
The currently used client to reconstruct the object with. Can either be a sync or async client.
131140
"""
132-
if isinstance(client.http, SyncHTTPClient):
133-
# Whenever the client is a SyncClient, we can safely assume that the http
134-
# attribute is a SyncHTTPClient, as this is the only HTTPClientT possible.
135-
sync_http: SyncHTTPClient = client.http
136-
return cls(data=data, http=sync_http) # type: ignore # Pyright cannot infer the type of cls
137-
else:
138-
# Whenever the client is a Client, we can safely assume that the http
139-
# attribute is a HTTPClient, as this is the only HTTPClientT possible.
140-
http: HTTPClient = client.http
141-
return cls(data=data, http=http) # type: ignore # Pyright cannot infer the type of cls
141+
# Even if we did an instance check here, Pyright cannot understand the narrowing of "http"
142+
# from the "client" parameter. We must ignore this error.
143+
http: HTTPClientT = client.http # type: ignore
144+
return cls(data=data, http=http)
142145

143146
def to_dict(self) -> DictT:
144147
"""Turns this object into a raw dictionary object. This is useful for when you

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ Documentation = "https://fortnite-api.readthedocs.io/en/rewrite/"
8686
asyncio_mode = "strict"
8787
testpaths = ["tests"]
8888
addopts = "--import-mode=importlib"
89+
log_cli = true
90+
log_cli_level = "INFO"
91+
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
92+
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
8993

9094
# Pyright configuration
9195

tests/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Fortnite API Library Tests
2+
3+
This library makes a large effort to ensure that the responses given from the API are stably transformed to their
4+
respective Python objects. Every client, both in the `Client` and `SyncClient` classes are tested. This file
5+
outlines how the tests are laid such that all these edge cases are handled.
6+
7+
## Generic Library Tests
8+
9+
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.
10+
11+
| Test File | Purpose and Logic |
12+
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
13+
| [`test_account.py`](test_account.py) | Ensures that an `Account` object is created properly and its dunder methods work as expected. |
14+
| [`test_aes.py`](test_aes.py) | Ensures that `Aes` object initializes properly by checking known dynamic keys and hashes. |
15+
| [`test_asset.py`](test_asset.py) | Ensures that the rules regulating `Asset` resizing are correct and that the asset reading functions function correctly. |
16+
| [`test_beta.py`](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. |
17+
| [`test_proxy.py`](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. |
18+
| [`test_ratelimits.py`](test_ratelimits.py) | Ensures that the library's handling of rate limits is correct, and related exceptions are raised as expected. |
19+
| [`test_repr.py`](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. |
20+
| [`test_methods.py`](test_methods.py) | The handling of all the functions on the `Client` and `SyncClient` class. See Edge Cases below for more information. |
21+
22+
### Edge Case Library Tests
23+
24+
Due to the library's complexity, especially considering that it fully supports both `async` and `sync` functionality,
25+
many edge cases are tested. Mainly, these tests are related to the `Client` and `SyncClient` classes, and the methods
26+
defined on them.
27+
28+
#### Definition and Tests for the Hybrid Client
29+
30+
##### Test Client Hybrid: `test_client_hybrid.py`
31+
32+
The tests define a custom `ClientHybrid` class in [`client/test_client_hybrid.py`](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.
33+
34+
Thus, all tests that make API calls will import and use the `ClientHybrid`.
35+
36+
As an example, consider the user requesting to call `fetch_aes()` using the `ClientHybrid`:
37+
38+
1. The `ClientHybrid` class is initialized as a context manager, the same as you would with a `Client`.
39+
2. The `fetch_aes()` method is called on the `ClientHybrid`.
40+
3. The sync method of `fetch_aes()` is called on an internally held `SyncClient` class.
41+
4. The async method of `fetch_aes()` is called on the `Client` itself.
42+
5. The result, if reconstructable or comparable, is checked to ensure that both returned objects are the same.
43+
6. The result of the async method call is returned as the final value.
44+
45+
This approach, although loop blocking in nature, ensures that the results from both the `Client` and `SyncClient` are the same.
46+
47+
##### Test Client: `test_client.py`
48+
49+
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.
50+
51+
#### Tests for the Methods on the Client: `test_methods.py`
52+
53+
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.
54+
55+
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.
56+
57+
#### Cosmetic Tests: `/cosmetics/*.py`
58+
59+
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`.

tests/client/test_client_hybrid.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""
2+
MIT License
3+
4+
Copyright (c) 2019-present Luc1412
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
The above copyright notice and this permission notice shall be included in all
14+
copies or substantial portions of the Software.
15+
16+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
SOFTWARE.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import inspect
28+
import logging
29+
from collections.abc import Callable, Coroutine
30+
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeAlias, TypeVar
31+
32+
import pytest
33+
import requests
34+
from typing_extensions import ParamSpec
35+
36+
import fortnite_api
37+
from fortnite_api import ReconstructAble
38+
39+
P = ParamSpec('P')
40+
T = TypeVar('T')
41+
42+
if TYPE_CHECKING:
43+
Client: TypeAlias = fortnite_api.Client
44+
SyncClient = fortnite_api.SyncClient
45+
46+
CoroFunc = Callable[P, Coroutine[Any, Any, T]]
47+
48+
log = logging.getLogger(__name__)
49+
50+
51+
class HybridMethodProxy(Generic[P, T]):
52+
def __init__(
53+
self,
54+
hybrid_client: ClientHybrid,
55+
sync_client: SyncClient,
56+
async_method: CoroFunc[Concatenate[Client, P], T],
57+
sync_method: Callable[Concatenate[SyncClient, P], T],
58+
) -> None:
59+
self.__hybrid_client = hybrid_client
60+
self.__sync_client = sync_client
61+
62+
self.__async_method = async_method
63+
self.__sync_method = sync_method
64+
65+
@property
66+
def __name__(self) -> str:
67+
return self.__async_method.__name__
68+
69+
def _validate_results(self, async_res: T, sync_res: T) -> None:
70+
assert type(async_res) is type(sync_res), f"Expected {type(async_res)}, got {type(sync_res)}"
71+
72+
if isinstance(async_res, fortnite_api.Hashable):
73+
assert isinstance(sync_res, fortnite_api.Hashable)
74+
assert async_res == sync_res
75+
log.debug('Hashable comparison passed for method %s.', self.__async_method.__name__)
76+
77+
if isinstance(async_res, fortnite_api.ReconstructAble):
78+
assert isinstance(sync_res, fortnite_api.ReconstructAble)
79+
80+
sync_res_narrowed: ReconstructAble[Any, fortnite_api.SyncHTTPClient] = sync_res
81+
async_res_narrowed: ReconstructAble[Any, fortnite_api.HTTPClient] = async_res
82+
83+
async_raw_data = sync_res_narrowed.to_dict()
84+
sync_raw_data = sync_res_narrowed.to_dict()
85+
assert async_raw_data == sync_raw_data
86+
log.debug('Raw data equality passed for method %s', self.__async_method.__name__)
87+
88+
async_reconstructed = type(async_res_narrowed).from_dict(async_raw_data, client=self.__hybrid_client)
89+
sync_reconstructed = type(sync_res_narrowed).from_dict(sync_raw_data, client=self.__sync_client)
90+
91+
assert isinstance(async_reconstructed, type(sync_reconstructed))
92+
assert type(async_reconstructed) is type(async_res_narrowed)
93+
assert type(sync_reconstructed) is type(sync_res_narrowed)
94+
log.debug('Reconstructed data equality passed for method %s', self.__async_method.__name__)
95+
96+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
97+
# Call the sync method first
98+
sync_result = self.__sync_method(self.__sync_client, *args, **kwargs)
99+
100+
# Call the async method
101+
async_result = await self.__async_method(self.__hybrid_client, *args, **kwargs)
102+
103+
log.debug('Validating results for %s', self.__async_method.__name__)
104+
self._validate_results(async_result, sync_result)
105+
return async_result
106+
107+
108+
class ClientHybrid(fortnite_api.Client):
109+
"""Denotes a "client-hybrid" that calls both a async and sync
110+
client when a method is called.
111+
112+
Pytest tests are not called in parallel, so although this is a
113+
blocking operation it will not affect the overall performance of
114+
the tests.
115+
"""
116+
117+
def __init__(self, *args: Any, **kwargs: Any) -> None:
118+
super().__init__(*args, **kwargs)
119+
120+
kwargs.pop('session', None)
121+
session = requests.Session()
122+
self.__sync_client: fortnite_api.SyncClient = fortnite_api.SyncClient(*args, session=session, **kwargs)
123+
self.__inject_hybrid_methods()
124+
125+
def __inject_hybrid_methods(self) -> None:
126+
# Walks through all the public coroutine methods in this class. If it finds one,
127+
# it will mark it as a hybrid proxy method with it and its sync counterpart.
128+
for key, value in fortnite_api.Client.__dict__.items():
129+
if inspect.iscoroutinefunction(value):
130+
sync_value = getattr(fortnite_api.SyncClient, key, None)
131+
if sync_value is not None and inspect.isfunction(sync_value):
132+
setattr(self, key, HybridMethodProxy(self, self.__sync_client, value, sync_value))
133+
134+
async def __aexit__(self, *args: Any) -> None:
135+
# We need to ensure that the sync client is also closed
136+
self.__sync_client.__exit__(*args)
137+
return await super().__aexit__(*args)
138+
139+
140+
@pytest.mark.asyncio
141+
async def test_hybrid_client():
142+
hybrid_client = ClientHybrid()
143+
144+
# Walk through all coroutines in the normal client - ensure that
145+
# every coro on the normal is a proxy method on the hybrid client.
146+
for key, value in fortnite_api.Client.__dict__.items():
147+
if inspect.iscoroutinefunction(value) and not key.startswith('_'):
148+
assert hasattr(hybrid_client, key)
149+
150+
method = getattr(hybrid_client, key)
151+
assert isinstance(method, HybridMethodProxy)

0 commit comments

Comments
 (0)