diff --git a/.github/workflows/test_and_lint.yml b/.github/workflows/test_and_lint.yml index c3e90bc..8450661 100644 --- a/.github/workflows/test_and_lint.yml +++ b/.github/workflows/test_and_lint.yml @@ -21,7 +21,7 @@ jobs: unittests: strategy: matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.8', '3.9', '3.10'] runs-on: ubuntu-latest name: Unit tests, Python ${{ matrix.python-version }} diff --git a/docs/source/conf.py b/docs/source/conf.py index 1fdb685..8275bdf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -17,11 +17,11 @@ # -- Project information ----------------------------------------------------- project = 'FlightplanDB-py' -copyright = '2021, PH-KDX' +copyright = '2022, PH-KDX' author = 'PH-KDX' # The full version, including alpha/beta/rc tags -release = '0.6.0' +release = '0.7.0' # readthedocs.io insists on the version field being filled for epub builds version = release diff --git a/docs/source/user/changelog.rst b/docs/source/user/changelog.rst index df73745..bd02e1b 100644 --- a/docs/source/user/changelog.rst +++ b/docs/source/user/changelog.rst @@ -1,6 +1,13 @@ Changelog -------------------- +0.7.0 +^^^^^^^^^^^^^^^^^^^^ +This is another complete rewrite of the library, in which it is entirely converted to async. +This should mean faster execution of parallel requests, and no blocking when called from +another async library. Support for Python 3.7 has been dropped in this release. Python 3.11 +is not yet supported as aiohttp does not yet support Python 3.11 at the time of release. + 0.6.0 ^^^^^^^^^^^^^^^^^^^^ This is a complete rewrite of the library, which moves functions out of classes. diff --git a/docs/source/user/introduction.rst b/docs/source/user/introduction.rst index 9d6c921..0eaa90e 100644 --- a/docs/source/user/introduction.rst +++ b/docs/source/user/introduction.rst @@ -11,8 +11,10 @@ For more information on Flight Plan Database, see their excellent `About page `_, being used in the library. Installation @@ -66,8 +68,9 @@ To test if the package has correctly installed, open a Python shell .. code-block:: python3 - import flightplandb - flightplandb.api.ping() + import flightplandb + import asyncio + asyncio.run(flightplandb.api.ping()) which should return ``StatusResponse(message='OK', errors=None)`` @@ -89,6 +92,10 @@ These calls, together with :meth:`flightplandb.api.ping()`, will not increment y The limit for unauthenticated users is IP-based, and is currently set to 100. The limit for authenticated users is key-based, and is currently set to 2500. +Please note that some functions which return an iterable, such as the user search or plan search, +can make multiple HTTP requests to fetch all the paginated information, thus increasing your request +count by more than 1. + .. _authentication: diff --git a/docs/source/user/quickstart.rst b/docs/source/user/quickstart.rst index 753a22e..7a79689 100644 --- a/docs/source/user/quickstart.rst +++ b/docs/source/user/quickstart.rst @@ -14,44 +14,46 @@ request limit from 100 to 2500. .. code-block:: python - import flightplandb as fpdb - - # obviously, substitute your own token - API_KEY = "VtF93tXp5IUZE307kPjijoGCUtBq4INmNTS4wlRG" - - # list all users named lemon - for user in fpdb.user.search(username="lemon"): - print(user) - - # fetch most relevant user named lemon - print(fpdb.user.fetch(username="lemon")) - - # fetch first 20 of lemon's plans - lemon_plans = fpdb.user.plans(username="lemon", limit=20) - for plan in lemon_plans: - print(plan) - - # define a query to search for all plans - query = fpdb.datatypes.PlanQuery(fromICAO="EHAM", - toICAO="EGLL") - # then search for the first three results of that query, sorted by distance - # the route is included, which requires authentication - resp = fpdb.plan.search( - plan_query=query, - include_route=True, - sort="distance" - limit=3, - key=API_KEY - ) - # and print each result in the response - for i in resp: - print(i) - - # fetch the weather for Schiphol Airport - print(fpdb.weather.fetch("EHAM")) - - # then check remaining requests by subtracting the requests made from the total limit - print(fpdb.api.limit_cap-fpdb.api.limit_used) + import flightplandb as fpdb + import asyncio + + # obviously, substitute your own token + API_KEY = "VtF93tXp5IUZE307kPjijoGCUtBq4INmNTS4wlRG" + + async def main(): + # list all users named lemon + async for user in fpdb.user.search(username="lemon"): + print(user) + + # fetch most relevant user named lemon + print(await fpdb.user.fetch(username="lemon")) + + # fetch first 20 of lemon's plans + lemon_plans = fpdb.user.plans(username="lemon", limit=20) + async for plan in lemon_plans: + print(plan) + + # define a query to search for all plans + query = fpdb.datatypes.PlanQuery(fromICAO="EHAM", + toICAO="EGLL") + # then search for the first three results of that query, sorted by distance + # the route is included, which requires authentication + resp = fpdb.plan.search( + plan_query=query, + include_route=True, + sort="distance" + limit=3, + key=API_KEY + ) + # and print each result in the response + async for i in resp: + print(i) + + # fetch the weather for Schiphol Airport + print(await fpdb.weather.fetch("EHAM")) + + # then check remaining requests by subtracting the requests made from the total limit + print((await fpdb.api.limit_cap())-(await fpdb.api.limit_used())) Try saving this program in a file in your project directory and running it. Experiment around with different commands to get a feel for the library. diff --git a/pytest.ini b/pytest.ini index 807c945..89c75ec 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,3 @@ [pytest] -addopts = --disable-socket \ No newline at end of file +addopts = --disable-socket +asyncio_mode=auto diff --git a/setup.py b/setup.py index fc71ee3..e460106 100644 --- a/setup.py +++ b/setup.py @@ -18,8 +18,7 @@ def get_version(rel_path): if line.startswith('__version__'): delim = '"' if '"' in line else "'" return line.split(delim)[1] - else: - raise RuntimeError("Unable to find version string.") + raise RuntimeError("Unable to find version string.") setup( @@ -38,26 +37,25 @@ def get_version(rel_path): packages=find_packages(where="src"), include_package_data=True, install_requires=[ - "requests==2.26.0", - "python-dateutil==2.8.2" + "aiohttp~=3.8.1", + "python-dateutil~=2.8.2" ], extras_require={ "dev": [ - "Sphinx==4.5.0", + "Sphinx==5.1.1", "sphinx-rtd-theme==1.0.0" ], "test": [ - "pytest~=6.2.5", - "pytest-mock~=3.6.1", - "pytest_socket~=0.4.1" + "pytest~=7.1.3", + "pytest-socket~=0.5.1", + "pytest-asyncio~=0.19.0" ] }, - python_requires='>=3.7.0', + python_requires='>=3.8.0', classifiers=[ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Intended Audience :: Developers", "Natural Language :: English", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/src/flightplandb/__init__.py b/src/flightplandb/__init__.py index a8272fd..3415d61 100644 --- a/src/flightplandb/__init__.py +++ b/src/flightplandb/__init__.py @@ -10,7 +10,7 @@ # Version of the flightplandb package -__version__ = "0.6.0" +__version__ = "0.7.0" from . import ( internal, exceptions, datatypes, diff --git a/src/flightplandb/api.py b/src/flightplandb/api.py index 58f21a9..bdb31cb 100644 --- a/src/flightplandb/api.py +++ b/src/flightplandb/api.py @@ -4,7 +4,7 @@ from flightplandb.datatypes import StatusResponse -def header_value(header_key: str, key: Optional[str] = None) -> str: +async def header_value(header_key: str, key: Optional[str] = None) -> str: """Gets header value for key. Do not call directly. Parameters @@ -20,11 +20,12 @@ def header_value(header_key: str, key: Optional[str] = None) -> str: The value corresponding to the passed key """ - headers = internal.get_headers(key=key) # Make 1 request to fetch headers + # Make 1 request to fetch headers + headers = await internal.get_headers(key=key) return headers[header_key] -def version(key: Optional[str] = None) -> int: +async def version(key: Optional[str] = None) -> int: """API version that returned the response. Parameters @@ -38,10 +39,10 @@ def version(key: Optional[str] = None) -> int: API version """ - return int(header_value(header_key="X-API-Version", key=key)) + return int(await header_value(header_key="X-API-Version", key=key)) -def units(key: Optional[str] = None) -> str: +async def units(key: Optional[str] = None) -> str: """The units system used for numeric values. https://flightplandatabase.com/dev/api#units @@ -56,10 +57,10 @@ def units(key: Optional[str] = None) -> str: AVIATION, METRIC or SI """ - return header_value(header_key="X-Units", key=key) + return await header_value(header_key="X-Units", key=key) -def limit_cap(key: Optional[str] = None) -> int: +async def limit_cap(key: Optional[str] = None) -> int: """The number of requests allowed per day, operated on an hourly rolling basis. i.e requests used between 19:00 and 20:00 will become available again at 19:00 the following day. API key authenticated requests get a @@ -77,10 +78,10 @@ def limit_cap(key: Optional[str] = None) -> int: number of allowed requests per day """ - return int(header_value(header_key="X-Limit-Cap", key=key)) + return int(await header_value(header_key="X-Limit-Cap", key=key)) -def limit_used(key: Optional[str] = None) -> int: +async def limit_used(key: Optional[str] = None) -> int: """The number of requests used in the current period by the presented API key or IP address. See :ref:`request-limits` for more details. @@ -96,10 +97,10 @@ def limit_used(key: Optional[str] = None) -> int: number of requests used in period """ - return int(header_value(header_key="X-Limit-Used", key=key)) + return int(await header_value(header_key="X-Limit-Used", key=key)) -def ping(key: Optional[str] = None) -> StatusResponse: +async def ping(key: Optional[str] = None) -> StatusResponse: """Checks API status to see if it is up Parameters @@ -113,11 +114,11 @@ def ping(key: Optional[str] = None) -> StatusResponse: OK 200 means the service is up and running. """ - resp = internal.get(path="", key=key) + resp = await internal.get(path="", key=key) return StatusResponse(**resp) -def revoke(key: str) -> StatusResponse: +async def revoke(key: str) -> StatusResponse: """Revoke the API key in use in the event it is compromised. See :ref:`authentication` for more details. @@ -138,5 +139,5 @@ def revoke(key: str) -> StatusResponse: occurred and the errors array will give further details. """ - resp = internal.get(path="/auth/revoke", key=key) + resp = await internal.get(path="/auth/revoke", key=key) return StatusResponse(**resp) diff --git a/src/flightplandb/internal.py b/src/flightplandb/internal.py index 30b1cec..804e2f3 100644 --- a/src/flightplandb/internal.py +++ b/src/flightplandb/internal.py @@ -19,13 +19,14 @@ """This file mostly contains internal functions called by the API, so you're unlikely to ever use them.""" -from typing import Generator, List, Dict, Union, Optional +from typing import AsyncIterable, List, Dict, Union, Optional +from base64 import b64encode from urllib.parse import urljoin import json -import requests -from requests.auth import HTTPBasicAuth -from requests.structures import CaseInsensitiveDict +import aiohttp + +from multidict import CIMultiDict from flightplandb.exceptions import status_handler @@ -36,12 +37,30 @@ url_base: str = "https://api.flightplandatabase.com" -def request(method: str, - path: str, return_format="native", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - json_data: Optional[Dict] = None, - key: Optional[str] = None) -> Union[Dict, bytes]: +def _auth_str(key): + """Returns a API auth string.""" + + if isinstance(key, str): + key = key.encode("latin1") + else: + raise ValueError("API key must be a string!") + + authstr = "Basic " + ( + b64encode(key + b":").strip().decode() + ) + + return authstr + + +async def request( + method: str, + path: str, + return_format="native", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + key: Optional[str] = None +) -> Union[Dict, bytes]: """General HTTP requests function for non-paginated results. Parameters @@ -122,24 +141,32 @@ def request(method: str, # then add it to the request headers params["Accept"] = return_format_encoded - resp = requests.request(method=method, - url=urljoin(url_base, path), - auth=HTTPBasicAuth(key, None), - headers=params, - json=json_data) + # set auth in headers if key is provided + if key is not None: + params["Authorization"] = _auth_str(key=key) - status_handler(resp.status_code, ignore_statuses) + async with aiohttp.ClientSession() as session: + async with session.request( + method=method, + url=urljoin(url_base, path), + headers=params, + json=json_data + ) as resp: - header = resp.headers + status_handler(resp.status, ignore_statuses) - if return_format == "native": - return header, resp.json() - else: - return header, resp.text # if the format is not a dict + header = resp.headers + + if return_format == "native": + return header, await resp.json() + else: + return header, await resp.text() # if the format is not a dict # and here go the specific non-paginated HTTP calls -def get_headers(key: Optional[str] = None) -> CaseInsensitiveDict: +async def get_headers( + key: Optional[str] = None +) -> CIMultiDict: """Calls :meth:`request()` for request headers. Parameters @@ -152,16 +179,21 @@ def get_headers(key: Optional[str] = None) -> CaseInsensitiveDict: CaseInsensitiveDict A dict of headers, but the keys are case-insensitive. """ - headers, _ = request(method="get", - path="", - key=key) + headers, _ = await request( + method="get", + path="", + key=key + ) return headers -def get(path: str, return_format="native", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - key: Optional[str] = None) -> Union[Dict, bytes]: +async def get( + path: str, + return_format="native", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + key: Optional[str] = None +) -> Union[Dict, bytes]: """Calls :meth:`request()` for get requests. Parameters @@ -191,20 +223,25 @@ def get(path: str, return_format="native", if not params: params = {} - _, resp = request(method="get", - path=path, - return_format=return_format, - ignore_statuses=ignore_statuses, - params=params, - key=key) + _, resp = await request( + method="get", + path=path, + return_format=return_format, + ignore_statuses=ignore_statuses, + params=params, + key=key + ) return resp -def post(path: str, return_format="native", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - json_data: Optional[Dict] = None, - key: Optional[str] = None) -> Union[Dict, bytes]: +async def post( + path: str, + return_format="native", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + key: Optional[str] = None +) -> Union[Dict, bytes]: """Calls :meth:`request()` for post requests. Parameters @@ -234,21 +271,26 @@ def post(path: str, return_format="native", if not params: params = {} - _, resp = request(method="post", - path=path, - return_format=return_format, - ignore_statuses=ignore_statuses, - params=params, - json_data=json_data, - key=key) + _, resp = await request( + method="post", + path=path, + return_format=return_format, + ignore_statuses=ignore_statuses, + params=params, + json_data=json_data, + key=key + ) return resp -def patch(path: str, return_format="native", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - json_data: Optional[Dict] = None, - key: Optional[str] = None) -> Union[Dict, bytes]: +async def patch( + path: str, + return_format="native", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + key: Optional[str] = None +) -> Union[Dict, bytes]: """Calls :meth:`request()` for patch requests. Parameters @@ -279,20 +321,25 @@ def patch(path: str, return_format="native", if not params: params = {} - _, resp = request(method="patch", - path=path, - return_format=return_format, - ignore_statuses=ignore_statuses, - params=params, - key=key, - json_data=json_data) + _, resp = await request( + method="patch", + path=path, + return_format=return_format, + ignore_statuses=ignore_statuses, + params=params, + key=key, + json_data=json_data + ) return resp -def delete(path: str, return_format="native", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - key: Optional[str] = None) -> Union[Dict, bytes]: +async def delete( + path: str, + return_format="native", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + key: Optional[str] = None +) -> Union[Dict, bytes]: """Calls :meth:`request()` for delete requests. Parameters @@ -321,21 +368,25 @@ def delete(path: str, return_format="native", if not params: params = {} - _, resp = request(method="delete", - path=path, - return_format=return_format, - ignore_statuses=ignore_statuses, - params=params, - key=key) + _, resp = await request( + method="delete", + path=path, + return_format=return_format, + ignore_statuses=ignore_statuses, + params=params, + key=key + ) return resp -def getiter(path: str, - limit: int = 100, - sort: str = "created", - ignore_statuses: Optional[List] = None, - params: Optional[Dict] = None, - key: Optional[str] = None) -> Generator[Dict, None, None]: +async def getiter( + path: str, + limit: int = 100, + sort: str = "created", + ignore_statuses: Optional[List] = None, + params: Optional[Dict] = None, + key: Optional[str] = None +) -> AsyncIterable[Dict]: """Get :meth:`request()` for paginated results. Parameters @@ -357,8 +408,8 @@ def getiter(path: str, Returns ------- - Generator[Dict, None, None] - A generator of dicts. Return format cannot be specified. + AsyncIterable[Dict] + An iterable of dicts. Return format cannot be specified. """ if not ignore_statuses: @@ -379,38 +430,42 @@ def getiter(path: str, params["sort"] = sort url = urljoin(url_base, path) - auth = HTTPBasicAuth(key, None) - session = requests.Session() + # set auth in headers if key is provided + if key is not None: + params["Authorization"] = _auth_str(key=key) + # initially no results have been fetched yet num_results = 0 - r_fpdb = session.get( - url=url, - params=params, - auth=auth) - status_handler(r_fpdb.status_code, ignore_statuses) - - # I detest responses which "may" be paginated - # therefore I choose to pretend that all pages are paginated - # if it is unpaginated I say it is paginated with 1 page - if 'X-Page-Count' in r_fpdb.headers: - num_pages = int(r_fpdb.headers['X-Page-Count']) - else: - num_pages = 1 - - # while page <= num_pages... - for page in range(0, num_pages): - params['page'] = page - r_fpdb = session.get(url=url, - params=params, - auth=auth) - status_handler(r_fpdb.status_code, ignore_statuses) - # ...keep cycling through pages... - for i in r_fpdb.json(): - # ...and return every dictionary in there... - yield i - num_results += 1 - # ...unless the result limit has been reached - if num_results == limit: - return + async with aiohttp.ClientSession() as session: + async with session.get( + url=url, + params=params, + ) as r_fpdb: + status_handler(r_fpdb.status, ignore_statuses) + + # I detest responses which "may" be paginated + # therefore I choose to pretend that all pages are paginated + # if it is unpaginated I say it is paginated with 1 page + if 'X-Page-Count' in r_fpdb.headers: + num_pages = int(r_fpdb.headers['X-Page-Count']) + else: + num_pages = 1 + + # while page <= num_pages... + for page in range(0, num_pages): + params['page'] = page + async with session.get( + url=url, + params=params + ) as r_fpdb: + status_handler(r_fpdb.status, ignore_statuses) + # ...keep cycling through pages... + for i in await r_fpdb.json(): + # ...and return every dictionary in there... + yield i + num_results += 1 + # ...unless the result limit has been reached + if num_results == limit: + return diff --git a/src/flightplandb/nav.py b/src/flightplandb/nav.py index f7eaa1e..f9d1d0a 100644 --- a/src/flightplandb/nav.py +++ b/src/flightplandb/nav.py @@ -1,10 +1,10 @@ """Commands related to navigation aids and airports.""" -from typing import Generator, List, Optional +from typing import AsyncIterable, List, Optional from flightplandb.datatypes import Airport, Track, SearchNavaid from flightplandb import internal -def airport(icao: str, key: Optional[str] = None) -> Airport: +async def airport(icao: str, key: Optional[str] = None) -> Airport: """Fetches information about an airport. Parameters @@ -25,11 +25,11 @@ def airport(icao: str, key: Optional[str] = None) -> Airport: No airport with the specified ICAO code was found. """ - resp = internal.get(path=f"/nav/airport/{icao}", key=key) + resp = await internal.get(path=f"/nav/airport/{icao}", key=key) return Airport(**resp) -def nats(key: Optional[str] = None) -> List[Track]: +async def nats(key: Optional[str] = None) -> List[Track]: """Fetches current North Atlantic Tracks. Parameters @@ -44,10 +44,13 @@ def nats(key: Optional[str] = None) -> List[Track]: """ return list( - map(lambda n: Track(**n), internal.get(path="/nav/NATS", key=key))) + map( + lambda n: Track(**n), await internal.get(path="/nav/NATS", key=key) + ) + ) -def pacots(key: Optional[str] = None) -> List[Track]: +async def pacots(key: Optional[str] = None) -> List[Track]: """Fetches current Pacific Organized Track System tracks. Parameters @@ -62,13 +65,18 @@ def pacots(key: Optional[str] = None) -> List[Track]: """ return list( - map(lambda t: Track(**t), internal.get(path="/nav/PACOTS", key=key))) + map( + lambda t: Track(**t), await internal.get( + path="/nav/PACOTS", key=key + ) + ) + ) -def search( +async def search( query: str, type_: Optional[str] = None, key: Optional[str] = None - ) -> Generator[SearchNavaid, None, None]: + ) -> AsyncIterable[SearchNavaid]: r"""Searches navaids using a query. Parameters @@ -84,8 +92,8 @@ def search( Yields ------- - Generator[SearchNavaid, None, None] - A generator of navaids with either a name or ident + AsyncIterable[SearchNavaid] + A iterable of navaids with either a name or ident matching the ``query`` """ @@ -95,5 +103,7 @@ def search( params["types"] = type_ else: raise ValueError(f"{type_} is not a valid Navaid type") - for i in internal.getiter(path="/search/nav", params=params, key=key): + async for i in internal.getiter( + path="/search/nav", params=params, key=key + ): yield SearchNavaid(**i) diff --git a/src/flightplandb/plan.py b/src/flightplandb/plan.py index a63ff2c..567f50e 100644 --- a/src/flightplandb/plan.py +++ b/src/flightplandb/plan.py @@ -1,5 +1,5 @@ """Flightplan-related commands.""" -from typing import Generator, Union, Optional +from typing import AsyncIterable, Union, Optional from flightplandb.datatypes import ( StatusResponse, PlanQuery, Plan, GenerateQuery @@ -7,9 +7,11 @@ from flightplandb import internal -def fetch(id_: int, - return_format: str = "native", - key: Optional[str] = None) -> Union[Plan, None, bytes]: +async def fetch( + id_: int, + return_format: str = "native", + key: Optional[str] = None +) -> Union[Plan, None, bytes]: # Underscore for id_ must be escaped as id\_ so sphinx shows the _. # However, this would raise W605. To fix this, a raw string is used. r""" @@ -40,7 +42,7 @@ def fetch(id_: int, No plan with the specified id was found. """ - request = internal.get( + request = await internal.get( path=f"/plan/{id_}", return_format=return_format, key=key @@ -52,9 +54,11 @@ def fetch(id_: int, return request # if the format is not a dict -def create(plan: Plan, - return_format: str = "native", - key: Optional[str] = None) -> Union[Plan, bytes]: +async def create( + plan: Plan, + return_format: str = "native", + key: Optional[str] = None +) -> Union[Plan, bytes]: """Creates a new flight plan. Requires authentication. @@ -81,7 +85,7 @@ def create(plan: Plan, otherwise unusable. """ - request = internal.post( + request = await internal.post( path="/plan/", return_format=return_format, json_data=plan.to_api_dict(), @@ -93,9 +97,11 @@ def create(plan: Plan, return request -def edit(plan: Plan, - return_format: str = "native", - key: Optional[str] = None) -> Union[Plan, bytes]: +async def edit( + plan: Plan, + return_format: str = "native", + key: Optional[str] = None +) -> Union[Plan, bytes]: """Edits a flight plan linked to your account. Requires authentication. @@ -126,7 +132,7 @@ def edit(plan: Plan, """ plan_data = plan.to_api_dict() - request = internal.patch( + request = await internal.patch( path=f"/plan/{plan_data['id']}", return_format=return_format, json_data=plan_data, @@ -138,8 +144,10 @@ def edit(plan: Plan, return request -def delete(id_: int, - key: Optional[str] = None) -> StatusResponse: +async def delete( + id_: int, + key: Optional[str] = None +) -> StatusResponse: r"""Deletes a flight plan that is linked to your account. Requires authentication. @@ -162,13 +170,17 @@ def delete(id_: int, No plan with the specified id was found. """ - resp = internal.delete(path=f"/plan/{id_}", key=key) + resp = await internal.delete(path=f"/plan/{id_}", key=key) return StatusResponse(**resp) -def search(plan_query: PlanQuery, sort: str = "created", - include_route: bool = False, limit: int = 100, - key: Optional[str] = None) -> Generator[Plan, None, None]: +async def search( + plan_query: PlanQuery, + sort: str = "created", + include_route: bool = False, + limit: int = 100, + key: Optional[str] = None +) -> AsyncIterable[Plan]: """Searches for flight plans. A number of search parameters are available. They will be combined to form a search request. @@ -191,24 +203,28 @@ def search(plan_query: PlanQuery, sort: str = "created", Yields ------- - Generator[Plan, None, None] - A generator containing :class:`~flightplandb.datatypes.Plan` + AsyncIterable[Plan] + An iterable containing :class:`~flightplandb.datatypes.Plan` objects. """ request_json = plan_query.to_api_dict() request_json["includeRoute"] = include_route - for i in internal.getiter(path="/search/plans", - sort=sort, - params=request_json, - limit=limit, - key=key): + async for i in internal.getiter( + path="/search/plans", + sort=sort, + params=request_json, + limit=limit, + key=key + ): yield Plan(**i) -def has_liked(id_: int, - key: Optional[str] = None) -> bool: +async def has_liked( + id_: int, + key: Optional[str] = None +) -> bool: r"""Fetches your like status for a flight plan. Requires authentication. @@ -226,15 +242,19 @@ def has_liked(id_: int, ``True``/``False`` to indicate that the plan was liked / not liked """ - resp = internal.get(path=f"/plan/{id_}/like", - ignore_statuses=[404], - key=key) - sr = StatusResponse(**resp) - return sr.message != "Not Found" + resp = await internal.get( + path=f"/plan/{id_}/like", + ignore_statuses=[404], + key=key + ) + status_response = StatusResponse(**resp) + return status_response.message != "Not Found" -def like(id_: int, - key: Optional[str] = None) -> StatusResponse: +async def like( + id_: int, + key: Optional[str] = None +) -> StatusResponse: r"""Likes a flight plan. Requires authentication. @@ -258,12 +278,14 @@ def like(id_: int, No plan with the specified id was found. """ - resp = internal.post(path=f"/plan/{id_}/like", key=key) + resp = await internal.post(path=f"/plan/{id_}/like", key=key) return StatusResponse(**resp) -def unlike(id_: int, - key: Optional[str] = None) -> bool: +async def unlike( + id_: int, + key: Optional[str] = None +) -> bool: r"""Removes a flight plan like. Requires authentication. @@ -287,13 +309,15 @@ def unlike(id_: int, or the plan was found but wasn't liked. """ - internal.delete(path=f"/plan/{id_}/like", key=key) + await internal.delete(path=f"/plan/{id_}/like", key=key) return True -def generate(gen_query: GenerateQuery, - include_route: bool = False, - key: Optional[str] = None) -> Union[Plan, bytes]: +async def generate( + gen_query: GenerateQuery, + include_route: bool = False, + key: Optional[str] = None +) -> Union[Plan, bytes]: """Creates a new flight plan using the route generator. Requires authentication. @@ -321,14 +345,18 @@ def generate(gen_query: GenerateQuery, # due to an API bug this must be a string instead of a boolean request_json["includeRoute"] = "true" if include_route else "false" - resp = internal.post(path="/auto/generate", - json_data=request_json, - key=key) + resp = await internal.post( + path="/auto/generate", + json_data=request_json, + key=key + ) return Plan(**resp) -def decode(route: str, - key: Optional[str] = None) -> Plan: +async def decode( + route: str, + key: Optional[str] = None +) -> Plan: """Creates a new flight plan using the route decoder. Requires authentication. @@ -359,6 +387,6 @@ def decode(route: str, arguments or was otherwise unusable. """ - resp = internal.post( + resp = await internal.post( path="/auto/decode", json_data={"route": route}, key=key) return Plan(**resp) diff --git a/src/flightplandb/tags.py b/src/flightplandb/tags.py index e40cabf..1f2961c 100644 --- a/src/flightplandb/tags.py +++ b/src/flightplandb/tags.py @@ -4,7 +4,7 @@ from flightplandb import internal -def fetch(key: Optional[str] = None) -> List[Tag]: +async def fetch(key: Optional[str] = None) -> List[Tag]: """Fetches current popular tags from all flight plans. Only tags with sufficient popularity are included. @@ -19,4 +19,6 @@ def fetch(key: Optional[str] = None) -> List[Tag]: A list of the current popular tags. """ - return list(map(lambda t: Tag(**t), internal.get(path="/tags", key=key))) + return list( + map(lambda t: Tag(**t), await internal.get(path="/tags", key=key)) + ) diff --git a/src/flightplandb/user.py b/src/flightplandb/user.py index cae3daf..2f42598 100644 --- a/src/flightplandb/user.py +++ b/src/flightplandb/user.py @@ -1,10 +1,10 @@ """Commands related to registered users.""" -from typing import Generator, Optional +from typing import AsyncIterable, Optional from flightplandb.datatypes import Plan, User, UserSmall from flightplandb import internal -def me(key: Optional[str] = None) -> User: +async def me(key: Optional[str] = None) -> User: """Fetches profile information for the currently authenticated user. Requires authentication. @@ -25,11 +25,11 @@ def me(key: Optional[str] = None) -> User: Authentication failed. """ - resp = internal.get(path="/me", key=key) + resp = await internal.get(path="/me", key=key) return User(**resp) -def fetch(username: str, key: Optional[str] = None) -> User: +async def fetch(username: str, key: Optional[str] = None) -> User: """Fetches profile information for any registered user Parameters @@ -50,13 +50,16 @@ def fetch(username: str, key: Optional[str] = None) -> User: No user was found with this username. """ - resp = internal.get(path=f"/user/{username}", key=key) + resp = await internal.get(path=f"/user/{username}", key=key) return User(**resp) -def plans(username: str, sort: str = "created", - limit: int = 100, - key: Optional[str] = None) -> Generator[Plan, None, None]: +async def plans( + username: str, + sort: str = "created", + limit: int = 100, + key: Optional[str] = None +) -> AsyncIterable[Plan]: """Fetches flight plans created by a user. Parameters @@ -73,20 +76,25 @@ def plans(username: str, sort: str = "created", Yields ------- - Generator[Plan, None, None] - A generator with all the flight plans a user created, + AsyncIterable[Plan] + An iterator with all the flight plans a user created, limited by ``limit`` """ - for i in internal.getiter(path=f"/user/{username}/plans", - sort=sort, - limit=limit, - key=key): + async for i in internal.getiter( + path=f"/user/{username}/plans", + sort=sort, + limit=limit, + key=key + ): yield Plan(**i) -def likes(username: str, sort: str = "created", - limit: int = 100, - key: Optional[str] = None) -> Generator[Plan, None, None]: +async def likes( + username: str, + sort: str = "created", + limit: int = 100, + key: Optional[str] = None +) -> AsyncIterable[Plan]: """Fetches flight plans liked by a user. Parameters @@ -103,21 +111,25 @@ def likes(username: str, sort: str = "created", Yields ------- - Generator[Plan, None, None] - A generator with all the flight plans a user liked, + AsyncIterable[Plan] + An iterable with all the flight plans a user liked, limited by ``limit`` """ - for i in internal.getiter(path=f"/user/{username}/likes", - sort=sort, - limit=limit, - key=key): + async for i in internal.getiter( + path=f"/user/{username}/likes", + sort=sort, + limit=limit, + key=key + ): yield Plan(**i) -def search(username: str, - limit=100, - key: Optional[str] = None) -> Generator[UserSmall, None, None]: +async def search( + username: str, + limit=100, + key: Optional[str] = None +) -> AsyncIterable[UserSmall]: """Searches for users by username. For more detailed info on a specific user, use :meth:`fetch` @@ -132,14 +144,16 @@ def search(username: str, Yields ------- - Generator[UserSmall, None, None] - A generator with a list of users approximately matching + AsyncIterable[UserSmall] + An iterable with a list of users approximately matching ``username``, limited by ``limit``. UserSmall is used instead of User, because less info is returned. """ - for i in internal.getiter(path="/search/users", - limit=limit, - params={"q": username}, - key=key): + async for i in internal.getiter( + path="/search/users", + limit=limit, + params={"q": username}, + key=key + ): yield UserSmall(**i) diff --git a/src/flightplandb/weather.py b/src/flightplandb/weather.py index b3feb1d..ad54370 100644 --- a/src/flightplandb/weather.py +++ b/src/flightplandb/weather.py @@ -4,7 +4,7 @@ from flightplandb import internal -def fetch(icao: str, key: Optional[str] = None) -> Weather: +async def fetch(icao: str, key: Optional[str] = None) -> Weather: """ Fetches current weather conditions at an airport @@ -26,4 +26,4 @@ def fetch(icao: str, key: Optional[str] = None) -> Weather: No airport with the specified ICAO code was found. """ - return Weather(**internal.get(path=f"/weather/{icao}", key=key)) + return Weather(**(await internal.get(path=f"/weather/{icao}", key=key))) diff --git a/tests/test_api.py b/tests/test_api.py index 1ed3866..8639d48 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,122 +1,110 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import StatusResponse # parametrise this for key and no key, perhaps -def test_api_header_value(mocker): +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get_headers") +async def test_api_header_value(patched_get_headers): json_response = { "X-Limit-Cap": "2000", "X-Limit-Used": "150" - } + } correct_response = "150" - def patched_get_headers(key): - return json_response - - mocker.patch( - target="flightplandb.internal.get_headers", - new=patched_get_headers) + patched_get_headers.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get_headers") - - response = flightplandb.api.header_value( + response = await flightplandb.api.header_value( header_key="X-Limit-Used", key="qwertyuiop" - ) + ) # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with( + patched_get_headers.assert_awaited_once_with( key="qwertyuiop" - ) + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_api_version(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.api.header_value") +async def test_api_version(patched_header_value): header_response = "1" correct_response = 1 - def patched_get(header_key, key): - return header_response - - mocker.patch( - target="flightplandb.api.header_value", - new=patched_get) + patched_header_value.return_value = header_response - spy = mocker.spy(flightplandb.api, "header_value") - - response = flightplandb.api.version() + response = await flightplandb.api.version() # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(header_key="X-API-Version", key=None) + patched_header_value.assert_awaited_once_with( + header_key="X-API-Version", key=None + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_api_units(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.api.header_value") +async def test_api_units(patched_header_value): header_response = "AVIATION" correct_response = "AVIATION" - def patched_get(header_key, key): - return header_response - - mocker.patch( - target="flightplandb.api.header_value", - new=patched_get) + patched_header_value.return_value = header_response - spy = mocker.spy(flightplandb.api, "header_value") - - response = flightplandb.api.units() + response = await flightplandb.api.units() # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(header_key="X-Units", key=None) + patched_header_value.assert_awaited_once_with( + header_key="X-Units", key=None + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_api_limit_cap(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.api.header_value") +async def test_api_limit_cap(patched_header_value): header_response = "100" correct_response = 100 - def patched_get(header_key, key): - return header_response - - mocker.patch( - target="flightplandb.api.header_value", - new=patched_get) + patched_header_value.return_value = header_response - spy = mocker.spy(flightplandb.api, "header_value") - - response = flightplandb.api.limit_cap() + response = await flightplandb.api.limit_cap() # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(header_key="X-Limit-Cap", key=None) + patched_header_value.assert_awaited_once_with( + header_key="X-Limit-Cap", key=None + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_api_limit_used(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.api.header_value") +async def test_api_limit_used(patched_header_value): header_response = "50" correct_response = 50 - def patched_get(header_key, key): - return header_response - - mocker.patch( - target="flightplandb.api.header_value", - new=patched_get) + patched_header_value.return_value = header_response - spy = mocker.spy(flightplandb.api, "header_value") - - response = flightplandb.api.limit_used() + response = await flightplandb.api.limit_used() # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(header_key="X-Limit-Used", key=None) + patched_header_value.assert_awaited_once_with( + header_key="X-Limit-Used", key=None + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_api_ping(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_api_ping(patched_internal_get): json_response = { "message": "OK", "errors": None @@ -124,23 +112,20 @@ def test_api_ping(mocker): correct_response = StatusResponse(message="OK", errors=None) - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.api.ping() + response = await flightplandb.api.ping() # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(path='', key=None) + patched_internal_get.assert_awaited_once_with( + path='', key=None + ) # check that API method decoded data correctly for given response assert response == correct_response -def test_key_revoke(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_key_revoke(patched_internal_get): json_response = { "message": "OK", "errors": None @@ -148,17 +133,12 @@ def test_key_revoke(mocker): correct_response = StatusResponse(message="OK", errors=None) - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) - - spy = mocker.spy(flightplandb.internal, "get") + patched_internal_get.return_value = json_response - response = flightplandb.api.revoke(key="qwertyuiop") + response = await flightplandb.api.revoke(key="qwertyuiop") # check that API method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/auth/revoke', key="qwertyuiop") + patched_internal_get.assert_awaited_once_with( + path='/auth/revoke', key="qwertyuiop" + ) # check that API method decoded data correctly for given response assert response == correct_response diff --git a/tests/test_nav.py b/tests/test_nav.py index f32b44f..fb70d7d 100644 --- a/tests/test_nav.py +++ b/tests/test_nav.py @@ -1,3 +1,5 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import ( Airport, Timezone, Runway, RunwayEnds, @@ -8,7 +10,19 @@ from dateutil.tz import tzutc -def test_airport_info(mocker): +class AsyncIter: + def __init__(self, items): + self.items = items + + async def __aiter__(self): + for item in self.items: + yield item + + +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_airport_info(patched_internal_get): json_response = { 'ICAO': 'EHAL', 'IATA': None, @@ -153,23 +167,20 @@ def test_airport_info(mocker): ) ) - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.nav.airport("EHAL") + response = await flightplandb.nav.airport("EHAL") # check that NavAPI method decoded data correctly for given response assert response == correct_response # check that NavAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/nav/airport/EHAL', key=None) + patched_internal_get.assert_awaited_once_with( + path='/nav/airport/EHAL', key=None + ) -def test_nats(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_nats(patched_internal_get): json_response = [ { 'ident': 'A', @@ -289,23 +300,20 @@ def test_nats(mocker): validTo=datetime.datetime( 2021, 4, 28, 19, 0, tzinfo=tzutc()))] - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.nav.nats() + response = await flightplandb.nav.nats() # check that NavAPI method decoded data correctly for given response assert response == correct_response # check that NavAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/nav/NATS', key=None) + patched_internal_get.assert_awaited_once_with( + path='/nav/NATS', key=None + ) -def test_pacots(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_pacots(patched_internal_get): json_response = [ { 'ident': 1, @@ -420,23 +428,20 @@ def test_pacots(mocker): validTo=datetime.datetime( 2021, 4, 28, 19, 0, tzinfo=tzutc()))] - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) - - spy = mocker.spy(flightplandb.internal, "get") + patched_internal_get.return_value = json_response - response = flightplandb.nav.pacots() + response = await flightplandb.nav.pacots() # check that NavAPI method decoded data correctly for given response assert response == correct_response # check that NavAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/nav/PACOTS', key=None) + patched_internal_get.assert_awaited_once_with( + path='/nav/PACOTS', key=None + ) -def test_navaid_search(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.getiter") +async def test_navaid_search(patched_internal_getiter): json_response = [ {'airportICAO': None, 'elevation': 1.0000000015200001, @@ -477,22 +482,18 @@ def test_navaid_search(mocker): name='SPIJKERBOOR VOR-DME') ] - correct_calls = [mocker.call( + correct_calls = [mock.call( path='/search/nav', params={'q': 'SPY'}, key=None)] - def patched_getiter(path, params=None, key=None): - return (i for i in json_response) - - mocker.patch( - target="flightplandb.internal.getiter", - new=patched_getiter) - - spy = mocker.spy(flightplandb.internal, "getiter") + patched_internal_getiter.return_value = AsyncIter(json_response) response = flightplandb.nav.search("SPY") # check that PlanAPI method decoded data correctly for given response - assert list(i for i in response) == correct_response_list + response_list = [] + async for i in response: + response_list.append(i) + assert response_list == correct_response_list # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_has_calls(correct_calls) + patched_internal_getiter.assert_has_calls(correct_calls) diff --git a/tests/test_plan.py b/tests/test_plan.py index 3f2d60e..97a54ad 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -1,3 +1,5 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import ( Plan, PlanQuery, User, Route, GenerateQuery, @@ -7,7 +9,19 @@ from dateutil.tz import tzutc -def test_plan_fetch(mocker): +class AsyncIter: + def __init__(self, items): + self.items = items + + async def __aiter__(self): + for item in self.items: + yield item + + +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_plan_fetch(patched_internal_get): json_response = { "id": 62373, "fromICAO": "KLAS", @@ -22,7 +36,7 @@ def test_plan_fetch(mocker): "downloads": 1, "popularity": 1, "notes": "", - "encodedPolyline": "aaf{E`|y}T|Ftf@px\\hpe@lnCxw Dbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + "encodedPolyline": "aaf{E`|y}T|Ftf@px\\hpe@lnCxw Dbsk@r", "createdAt": "2015-08-04T20:48:08.000Z", "updatedAt": "2015-08-04T20:48:08.000Z", "tags": [ @@ -50,7 +64,7 @@ def test_plan_fetch(mocker): downloads=1, popularity=1, notes="", - encodedPolyline="aaf{E`|y}T|Ftf@px\\hpe@lnCxw Dbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + encodedPolyline="aaf{E`|y}T|Ftf@px\\hpe@lnCxw Dbsk@r", createdAt="2015-08-04T20:48:08.000Z", updatedAt="2015-08-04T20:48:08.000Z", tags=[ @@ -63,23 +77,20 @@ def test_plan_fetch(mocker): location=None )) - def patched_get(path, return_format, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.plan.fetch(62373) + response = await flightplandb.plan.fetch(62373) # check that PlanAPI method decoded data correctly for given response assert response == correct_response # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/plan/62373', return_format='native', key=None) + patched_internal_get.assert_awaited_once_with( + path='/plan/62373', return_format='native', key=None + ) -def test_plan_create(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.post") +async def test_plan_create(patched_internal_post): json_response = { "id": None, "fromICAO": "EHAM", @@ -206,23 +217,20 @@ def test_plan_create(mocker): 'key': None } - def patched_post(path, return_format, json_data, key): - return json_response - - mocker.patch( - target="flightplandb.internal.post", - new=patched_post) - - spy = mocker.spy(flightplandb.internal, "post") + patched_internal_post.return_value = json_response - response = flightplandb.plan.create(request_data) + response = await flightplandb.plan.create(request_data) # check that PlanAPI method decoded data correctly for given response assert response == correct_response # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(**correct_call) + patched_internal_post.assert_awaited_once_with( + **correct_call + ) -def test_plan_delete(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.delete") +async def test_plan_delete(patched_internal_delete): json_response = { "message": "OK", "errors": None @@ -230,23 +238,20 @@ def test_plan_delete(mocker): correct_response = StatusResponse(message="OK", errors=None) - def patched_delete(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.delete", - new=patched_delete) + patched_internal_delete.return_value = json_response - spy = mocker.spy(flightplandb.internal, "delete") - - response = flightplandb.plan.delete(62493) + response = await flightplandb.plan.delete(62493) # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/plan/62493', key=None) + patched_internal_delete.assert_awaited_once_with( + path='/plan/62493', key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response -def test_plan_edit(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.patch") +async def test_plan_edit(patched_internal_patch): json_response = { "id": 23896, "fromICAO": "EHAM", @@ -381,23 +386,20 @@ def test_plan_edit(mocker): 'key': None } - def patched_patch(path, return_format, json_data, key): - return json_response + patched_internal_patch.return_value = json_response - mocker.patch( - target="flightplandb.internal.patch", - new=patched_patch) - - spy = mocker.spy(flightplandb.internal, "patch") - - response = flightplandb.plan.edit(plan=request_data, return_format="native", key=None) + response = await flightplandb.plan.edit( + plan=request_data, return_format="native", key=None + ) # check that PlanAPI method decoded data correctly for given response assert response == correct_response # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(**correct_call) + patched_internal_patch.assert_called_once_with(**correct_call) -def test_plan_search(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.getiter") +async def test_plan_search(patched_internal_getiter): json_response = [ { 'application': None, @@ -502,7 +504,7 @@ def test_plan_search(mocker): cycle=Cycle(id=5, ident='FPD1809', year=18, release=9)) ] - correct_calls = [mocker.call( + correct_calls = [mock.call( path='/search/plans', sort='created', params={ @@ -522,14 +524,7 @@ def test_plan_search(mocker): limit=2, key=None)] - def patched_getiter(path, sort="created", params=None, limit=100, key=None): - return (i for i in json_response) - - mocker.patch( - target="flightplandb.internal.getiter", - new=patched_getiter) - - spy = mocker.spy(flightplandb.internal, "getiter") + patched_internal_getiter.return_value = AsyncIter(json_response) response = flightplandb.plan.search( PlanQuery( @@ -537,12 +532,17 @@ def patched_getiter(path, sort="created", params=None, limit=100, key=None): toICAO="EHAL"), limit=2) # check that PlanAPI method decoded data correctly for given response - assert list(i for i in response) == correct_response_list + response_list = [] + async for i in response: + response_list.append(i) + assert response_list == correct_response_list # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_has_calls(correct_calls) + patched_internal_getiter.assert_has_calls(correct_calls) -def test_plan_like(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.post") +async def test_plan_like(patched_internal_post): json_response = { "message": "Not Found", "errors": None @@ -550,23 +550,20 @@ def test_plan_like(mocker): correct_response = StatusResponse(message='Not Found', errors=None) - def patched_post(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.post", - new=patched_post) + patched_internal_post.return_value = json_response - spy = mocker.spy(flightplandb.internal, "post") - - response = flightplandb.plan.like(42) + response = await flightplandb.plan.like(42) # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/plan/42/like', key=None) + patched_internal_post.assert_awaited_once_with( + path='/plan/42/like', key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response -def test_plan_unlike(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.delete") +async def test_plan_unlike(patched_internal_delete): json_response = { "message": "OK", "errors": None @@ -574,23 +571,20 @@ def test_plan_unlike(mocker): correct_response = True - def patched_delete(path, key): - return json_response + patched_internal_delete.return_value = json_response - mocker.patch( - target="flightplandb.internal.delete", - new=patched_delete) - - spy = mocker.spy(flightplandb.internal, "delete") - - response = flightplandb.plan.unlike(42) + response = await flightplandb.plan.unlike(42) # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/plan/42/like', key=None) + patched_internal_delete.assert_awaited_once_with( + path='/plan/42/like', key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response -def test_plan_has_liked(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_plan_has_liked(patched_internal_get): json_response = { "message": "OK", "errors": None @@ -598,23 +592,20 @@ def test_plan_has_liked(mocker): correct_response = True - def patched_get(path, ignore_statuses=None, key=None): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.plan.has_liked(42) + response = await flightplandb.plan.has_liked(42) # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/plan/42/like', ignore_statuses=[404], key=None) + patched_internal_get.assert_awaited_once_with( + path='/plan/42/like', ignore_statuses=[404], key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response -def test_plan_generate(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.post") +async def test_plan_generate(patched_internal_post): json_response = { 'application': None, 'createdAt': '2021-04-28T19:55:45.000Z', @@ -733,23 +724,20 @@ def test_plan_generate(mocker): "key": None } - def patched_post(path, json_data, key): - return json_response - - mocker.patch( - target="flightplandb.internal.post", - new=patched_post) - - spy = mocker.spy(flightplandb.internal, "post") + patched_internal_post.return_value = json_response - response = flightplandb.plan.generate(request_data) + response = await flightplandb.plan.generate(request_data) # check that PlanAPI method decoded data correctly for given response assert response == correct_response # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(**correct_call) + patched_internal_post.assert_awaited_once_with( + **correct_call + ) -def test_plan_decode(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.post") +async def test_plan_decode(patched_internal_post): json_response = { "id": 4708699, "fromICAO": "KSAN", @@ -840,17 +828,12 @@ def test_plan_decode(mocker): "key": None } - def patched_post(path, json_data, key): - return json_response - - mocker.patch( - target="flightplandb.internal.post", - new=patched_post) + patched_internal_post.return_value = json_response - spy = mocker.spy(flightplandb.internal, "post") - - response = flightplandb.plan.decode(request_data) + response = await flightplandb.plan.decode(request_data) # check that PlanAPI method decoded data correctly for given response assert response == correct_response # check that PlanAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(**correct_call) + patched_internal_post.assert_awaited_once_with( + **correct_call + ) diff --git a/tests/test_tags.py b/tests/test_tags.py index 9784c19..3fbb8ad 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,8 +1,13 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import Tag -def test_tags_api(mocker): +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_tags_api(patched_internal_get): json_response = [ { "name": "Decoded", @@ -29,17 +34,12 @@ def test_tags_api(mocker): popularity=0.009036140132228622) ] - def patched_get(path, key): - return json_response + patched_internal_get.return_value = json_response - mocker.patch( - target='flightplandb.internal.get', - new=patched_get) - - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.tags.fetch() + response = await flightplandb.tags.fetch() # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/tags', key=None) + patched_internal_get.assert_awaited_once_with( + path='/tags', key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response diff --git a/tests/test_user.py b/tests/test_user.py index ff3bc96..bc5c5e3 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,10 +1,24 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import User, Plan, UserSmall import datetime from dateutil.tz import tzutc -def test_self_info(mocker): +class AsyncIter: + def __init__(self, items): + self.items = items + + async def __aiter__(self): + for item in self.items: + yield item + + +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_self_info(patched_internal_get): json_response = { "id": 18990, "username": "discordflightplannerbot", @@ -35,23 +49,20 @@ def test_self_info(mocker): plansLikes=0 ) - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) + patched_internal_get.return_value = json_response - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.user.me() + response = await flightplandb.user.me() # check that UserAPI method decoded data correctly for given response assert response == correct_response # check that UserAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/me', key=None) + patched_internal_get.assert_awaited_once_with( + path='/me', key=None + ) -def test_user_info(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_user_info(patched_internal_get): json_response = { "id": 1, "username": "lemon", @@ -82,23 +93,20 @@ def test_user_info(mocker): plansLikes=33 ) - def patched_get(path, key): - return json_response - - mocker.patch( - target="flightplandb.internal.get", - new=patched_get) - - spy = mocker.spy(flightplandb.internal, "get") + patched_internal_get.return_value = json_response - response = flightplandb.user.fetch("lemon") + response = await flightplandb.user.fetch("lemon") # check that UserAPI method decoded data correctly for given response assert response == correct_response # check that UserAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/user/lemon', key=None) + patched_internal_get.assert_awaited_once_with( + path='/user/lemon', key=None + ) -def test_user_plans(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.getiter") +async def test_user_plans(patched_internal_getiter): json_response = [ { "id": 62373, @@ -114,7 +122,7 @@ def test_user_plans(mocker): "downloads": 1, "popularity": 1, "notes": "", - "encodedPolyline": r"aaf{E`|y}T|Ftf@px\\hpe@lnCxw \Dbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + "encodedPolyline": r"aaf{E`|y}T|Ftf@px\\hp e@`nnDd~f@zkmH", "createdAt": "2015-08-04T20:48:08.000Z", "updatedAt": "2015-08-04T20:48:08.000Z", "tags": [ @@ -167,7 +175,7 @@ def test_user_plans(mocker): downloads=1, popularity=1, notes="", - encodedPolyline=r"aaf{E`|y}T|Ftf@px\\hpe@lnCxw \Dbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + encodedPolyline=r"aaf{E`|y}T|Ftf@px\\hp e@`nnDd~f@zkmH", createdAt="2015-08-04T20:48:08.000Z", updatedAt="2015-08-04T20:48:08.000Z", tags=[ @@ -205,27 +213,28 @@ def test_user_plans(mocker): ) ] - def patched_getiter(path, limit, sort, key): - return (i for i in json_response) - - mocker.patch( - target="flightplandb.internal.getiter", - new=patched_getiter) + correct_calls = [mock.call( + path='/user/lemon/plans', + limit=100, + sort='created', + key=None + )] - spy = mocker.spy(flightplandb.internal, "getiter") + patched_internal_getiter.return_value = AsyncIter(json_response) response = flightplandb.user.plans("lemon") # check that UserAPI method decoded data correctly for given response - assert list(i for i in response) == correct_response_list + response_list = [] + async for i in response: + response_list.append(i) + assert response_list == correct_response_list # check that UserAPI method made correct request of FlightPlanDB - spy.assert_has_calls([mocker.call( - path='/user/lemon/plans', - limit=100, - sort='created', - key=None)]) + patched_internal_getiter.assert_has_calls(correct_calls) -def test_user_likes(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.getiter") +async def test_user_likes(patched_internal_getiter): json_response = [ { "id": 62373, @@ -241,7 +250,7 @@ def test_user_likes(mocker): "downloads": 1, "popularity": 1, "notes": "", - "encodedPolyline": r"aaf{E`|y}T|Ftf@px\\hpe@lnCxwDbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + "encodedPolyline": r"aaf{E`|y}T|Ftf@px\\hp e@`nnDd~f@zkmH", "createdAt": "2015-08-04T20:48:08.000Z", "updatedAt": "2015-08-04T20:48:08.000Z", "tags": [ @@ -294,7 +303,7 @@ def test_user_likes(mocker): downloads=1, popularity=1, notes="", - encodedPolyline=r"aaf{E`|y}T|Ftf@px\\hpe@lnCxwDbsk@rfx@vhjC`nnDd~f@zkv@nb~ChdmH", + encodedPolyline=r"aaf{E`|y}T|Ftf@px\\hp e@`nnDd~f@zkmH", createdAt="2015-08-04T20:48:08.000Z", updatedAt="2015-08-04T20:48:08.000Z", tags=[ @@ -332,27 +341,28 @@ def test_user_likes(mocker): ) ] - def patched_getiter(path, limit, sort, key): - return (i for i in json_response) - - mocker.patch( - target="flightplandb.internal.getiter", - new=patched_getiter) + correct_calls = [mock.call( + path='/user/lemon/likes', + limit=100, + sort='created', + key=None + )] - spy = mocker.spy(flightplandb.internal, "getiter") + patched_internal_getiter.return_value = AsyncIter(json_response) response = flightplandb.user.likes("lemon") # check that UserAPI method decoded data correctly for given response - assert list(i for i in response) == correct_response_list + response_list = [] + async for i in response: + response_list.append(i) + assert response_list == correct_response_list # check that UserAPI method made correct request of FlightPlanDB - spy.assert_has_calls([mocker.call( - path='/user/lemon/likes', - limit=100, - sort='created', - key=None)]) + patched_internal_getiter.assert_has_calls(correct_calls) -def test_user_search(mocker): +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.getiter") +async def test_user_search(patched_internal_getiter): json_response = [ {"id": 1, "username": 'lemon', @@ -386,21 +396,20 @@ def test_user_search(mocker): gravatarHash='b807060d00c10513ce04b70918dd07a1') ] - def patched_getiter(path, limit, params, key): - return (i for i in json_response) - - mocker.patch( - target="flightplandb.internal.getiter", - new=patched_getiter) + correct_calls = [mock.call( + path='/search/users', + limit=100, + params={'q': 'lemon'}, + key=None + )] - spy = mocker.spy(flightplandb.internal, "getiter") + patched_internal_getiter.return_value = AsyncIter(json_response) response = flightplandb.user.search("lemon") # check that UserAPI method decoded data correctly for given response - assert list(i for i in response) == correct_response_list + response_list = [] + async for i in response: + response_list.append(i) + assert response_list == correct_response_list # check that UserAPI method made correct request of FlightPlanDB - spy.assert_has_calls([mocker.call( - path='/search/users', - limit=100, - params={'q': 'lemon'}, - key=None)]) + patched_internal_getiter.assert_has_calls(correct_calls) diff --git a/tests/test_weather.py b/tests/test_weather.py index 2a4b7c4..ea15579 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -1,8 +1,13 @@ +from unittest import mock +import pytest import flightplandb from flightplandb.datatypes import Weather -def test_weather_api(mocker): +# localhost is set on every test to allow async loops +@pytest.mark.allow_hosts(['127.0.0.1', '::1']) +@mock.patch("flightplandb.internal.get") +async def test_weather_api(patched_internal_get): json_response = { "METAR": "EHAM 250755Z 02009KT 330V130 9999\ BKN033 07/M00 Q1029 NOSIG", @@ -17,17 +22,12 @@ def test_weather_api(mocker): 2507/2510 CAVOK BECMG 2608/2611 05009KT" ) - def patched_get(path, key): - return json_response + patched_internal_get.return_value = json_response - mocker.patch( - target='flightplandb.internal.get', - new=patched_get) - - spy = mocker.spy(flightplandb.internal, "get") - - response = flightplandb.weather.fetch("EHAM") + response = await flightplandb.weather.fetch("EHAM") # check that TagsAPI method made correct request of FlightPlanDB - spy.assert_called_once_with(path='/weather/EHAM', key=None) + patched_internal_get.assert_awaited_once_with( + path='/weather/EHAM', key=None + ) # check that TagsAPI method decoded data correctly for given response assert response == correct_response