diff --git a/ancv/web/client.py b/ancv/web/client.py index 823ec5e..9c914a7 100644 --- a/ancv/web/client.py +++ b/ancv/web/client.py @@ -2,7 +2,6 @@ from http import HTTPStatus from types import SimpleNamespace -import aiohttp import gidgethub from gidgethub.aiohttp import GitHubAPI from humanize import naturalsize @@ -20,7 +19,6 @@ async def get_resume( user: str, - session: aiohttp.ClientSession, github: GitHubAPI, stopwatch: Stopwatch, filename: str = "resume.json", @@ -46,7 +44,6 @@ async def get_resume( Args: user: The GitHub username to fetch the resume from. - session: The `aiohttp.ClientSession` to use for the request. github: The API object to use for the request. stopwatch: The `Stopwatch` to use for timing. filename: The name of the file to look for in the user's gists. @@ -56,7 +53,7 @@ async def get_resume( The parsed resume. """ - log = LOGGER.bind(user=user, session=session) + log = LOGGER.bind(user=user) stopwatch("Fetching Gists") gists = github.getiter(f"/users/{user}/gists") diff --git a/ancv/web/server.py b/ancv/web/server.py index 2bfc978..b3f4d28 100644 --- a/ancv/web/server.py +++ b/ancv/web/server.py @@ -185,16 +185,13 @@ async def username(self, request: web.Request) -> web.Response: # Implicit 'downcasting' from `Any` doesn't require an explicit `cast` call, just # regular type hints: # https://adamj.eu/tech/2021/07/06/python-type-hints-how-to-use-typing-cast/ - session: ClientSession = request.app["client_session"] github: GitHubAPI = request.app["github"] log = log.bind(user=user) stopwatch.stop() try: - resume = await get_resume( - user=user, session=session, github=github, stopwatch=stopwatch - ) + resume = await get_resume(user=user, github=github, stopwatch=stopwatch) except ResumeLookupError as e: stopwatch.stop() log.warning(str(e)) diff --git a/pyproject.toml b/pyproject.toml index 7ded164..c350a63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dev-dependencies = [ "pydeps>=2.0.1", "pytest-aiohttp>=1.0.5", "pytest-cov>=5.0.0", + "pytest-asyncio>=0.24.0", "pytest-rerunfailures>=14.0", "pytest>=8.3.3", "requests>=2.32.3", @@ -82,6 +83,10 @@ fail_under = 80.0 [tool.datamodel-codegen] target-python-version = "3.12" +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + [tool.mypy] mypy_path = "stubs/" show_error_codes = true diff --git a/tests/web/test_client.py b/tests/web/test_client.py index fdc4c8c..1f65ad1 100644 --- a/tests/web/test_client.py +++ b/tests/web/test_client.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import nullcontext as does_not_raise from typing import ContextManager @@ -19,14 +20,22 @@ def stopwatch(): @pytest.fixture(scope="function") -async def client_session(): +async def client_session() -> aiohttp.ClientSession: + assert ( + asyncio.get_running_loop() + ), "`aiohttp.ClientSession` constructor will need loop running." + + # The constructor accesses the async event loop, and if none is running errors. So + # this pytest fixture function needs to be marked `async` for `pytest-asyncio` with + # the `asyncio_mode = "auto"` option set to pick it up automatically and *provide* a + # loop. return aiohttp.ClientSession() @pytest.fixture(scope="function") -async def gh_api(client_session): +def gh_api(client_session: aiohttp.ClientSession): return GitHubAPI( - await client_session, + client_session, requester=f"{METADATA.name}-PYTEST-REQUESTER", oauth_token=GH_TOKEN, ) @@ -67,23 +76,22 @@ async def gh_api(client_session): ), ], ) -@pytest.mark.asyncio @gh_rate_limited async def test_get_resume_validations( username: str, - client_session: aiohttp.ClientSession, - gh_api: GitHubAPI, - stopwatch: Stopwatch, size_limit: int, filename: str, expectation: ContextManager, + # Fixtures: + gh_api: GitHubAPI, + stopwatch: Stopwatch, ) -> None: - api = await gh_api + assert asyncio.get_running_loop() + with expectation: await get_resume( user=username, - session=client_session, - github=api, + github=gh_api, stopwatch=stopwatch, filename=filename, size_limit=size_limit, diff --git a/tests/web/test_server.py b/tests/web/test_server.py index 24095c6..4fcb984 100644 --- a/tests/web/test_server.py +++ b/tests/web/test_server.py @@ -1,3 +1,4 @@ +import asyncio from contextlib import nullcontext as does_not_raise from datetime import timedelta from http import HTTPStatus @@ -59,7 +60,6 @@ def test_is_terminal_client(user_agent: str, expected: bool) -> None: @pytest.mark.filterwarnings("ignore:Request.message is deprecated") # No idea... @pytest.mark.filterwarnings("ignore:Exception ignored in") # No idea... -@pytest.mark.asyncio class TestApiHandler: @pytest.mark.parametrize( ["user_agent", "expected_http_code"], @@ -80,8 +80,9 @@ async def test_root_endpoint( expected_http_code: HTTPStatus, aiohttp_client: Any, api_client_app: Application, - event_loop: Any, ) -> None: + assert asyncio.get_running_loop() + client = await aiohttp_client(api_client_app) resp: ClientResponse = await client.get( @@ -139,8 +140,9 @@ async def test_username_endpoint( expected_error_message: Optional[str], aiohttp_client: Any, api_client_app: Application, - event_loop: Any, ) -> None: + assert asyncio.get_running_loop() + client = await aiohttp_client(api_client_app) resp = await client.get(f"/{username}") @@ -153,8 +155,9 @@ async def test_showcase_endpoint( self, aiohttp_client: Any, api_client_app: Application, - event_loop: Any, ) -> None: + assert asyncio.get_running_loop() + client = await aiohttp_client(api_client_app) resp: ClientResponse = await client.get(f"/{_SHOWCASE_USERNAME}") @@ -174,8 +177,9 @@ async def test_return_content( expected_contained_text: str, aiohttp_client: Any, api_client_app: Application, - event_loop: Any, ) -> None: + assert asyncio.get_running_loop() + client = await aiohttp_client(api_client_app) resp = await client.get(f"/{username}") @@ -186,7 +190,6 @@ async def test_return_content( @pytest.mark.filterwarnings("ignore:Request.message is deprecated") # No idea... @pytest.mark.filterwarnings("ignore:Exception ignored in") # No idea... -@pytest.mark.asyncio class TestFileHandler: @pytest.mark.parametrize( ["expected_http_code", "expected_str_content"], @@ -202,8 +205,9 @@ async def test_root_endpoint( expected_str_content: str, aiohttp_client: Any, file_handler_app: Application, - event_loop: Any, ) -> None: + assert asyncio.get_running_loop() + client = await aiohttp_client(file_handler_app) resp: ClientResponse = await client.get("/") diff --git a/uv.lock b/uv.lock index 3d94279..90ecf57 100644 --- a/uv.lock +++ b/uv.lock @@ -97,6 +97,7 @@ dev = [ { name = "pydeps" }, { name = "pytest" }, { name = "pytest-aiohttp" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-rerunfailures" }, { name = "requests" }, @@ -125,6 +126,7 @@ dev = [ { name = "pydeps", specifier = ">=2.0.1" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-aiohttp", specifier = ">=1.0.5" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-rerunfailures", specifier = ">=14.0" }, { name = "requests", specifier = ">=2.32.3" },