-
Notifications
You must be signed in to change notification settings - Fork 1
Fix VCRpy config and add asyncio fixtures #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
16f2a57
0f564e8
e1dcbc3
3ee2af8
054a5bd
1c3a1e6
2e78e32
b37b08a
84c8a06
e7a5c9f
74e3908
38fff63
9d9fd3e
c0454a7
af7da0b
ca9d214
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||||||||||||
| import asyncio | ||||||||||||||||
|
|
||||||||||||||||
| import pytest | ||||||||||||||||
| from backend_api.background_tasks import background_task_exceptions | ||||||||||||||||
| from backend_api.background_tasks import background_tasks_set | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| async def _wait_for_tasks(tasks_list: list[asyncio.Task[None]]): | ||||||||||||||||
| _, pending = await asyncio.wait(tasks_list, timeout=5.0) | ||||||||||||||||
| if pending: | ||||||||||||||||
| raise RuntimeError(f"There are still pending tasks: {pending}") | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| @pytest.fixture(autouse=True) | ||||||||||||||||
| def fail_on_background_task_errors(): | ||||||||||||||||
| """Automatically fail tests if ANY background task raises an exception.""" | ||||||||||||||||
| background_task_exceptions.clear() | ||||||||||||||||
|
|
||||||||||||||||
| yield | ||||||||||||||||
|
|
||||||||||||||||
| # Wait for background tasks to complete (using asyncio.run for sync fixture) | ||||||||||||||||
| if background_tasks_set: | ||||||||||||||||
| tasks_list = list(background_tasks_set) | ||||||||||||||||
| try: | ||||||||||||||||
| loop = asyncio.get_running_loop() | ||||||||||||||||
| except RuntimeError: | ||||||||||||||||
| asyncio.run(_wait_for_tasks(tasks_list)) | ||||||||||||||||
| else: | ||||||||||||||||
| loop.run_until_complete(_wait_for_tasks(tasks_list)) | ||||||||||||||||
|
Comment on lines
+24
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix the event loop handling to support async test functions. The logic on Line 29 attempts to call The fixture cannot call
Consider this approach if pytest-asyncio is available: +import pytest_asyncio
+
+# For async tests
+@pytest_asyncio.fixture(autouse=True)
+async def fail_on_background_task_errors_async():
+ """Automatically fail async tests if ANY background task raises an exception."""
+ background_task_exceptions.clear()
+
+ yield
+
+ if background_tasks_set:
+ tasks_list = list(background_tasks_set)
+ await _wait_for_tasks(tasks_list)
+
+ if background_task_exceptions:
+ pytest.fail(
+ f"Background tasks raised {len(background_task_exceptions)} exception(s):\n"
+ + "\n\n".join(f"{type(e).__name__}: {e}" for e in background_task_exceptions)
+ )
+
+# For sync tests
@pytest.fixture(autouse=True)
def fail_on_background_task_errors():
"""Automatically fail tests if ANY background task raises an exception."""
background_task_exceptions.clear()
yield
- # Wait for background tasks to complete (using asyncio.run for sync fixture)
+ # Wait for background tasks to complete (only for sync tests)
if background_tasks_set:
tasks_list = list(background_tasks_set)
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- asyncio.run(_wait_for_tasks(tasks_list))
- else:
- loop.run_until_complete(_wait_for_tasks(tasks_list))
+ asyncio.run(_wait_for_tasks(tasks_list))
# Fail if any exceptions occurred
if background_task_exceptions:
pytest.fail(
f"Background tasks raised {len(background_task_exceptions)} exception(s):\n"
+ "\n\n".join(f"{type(e).__name__}: {e}" for e in background_task_exceptions)
)📝 Committable suggestion
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| # Fail if any exceptions occurred | ||||||||||||||||
| if background_task_exceptions: | ||||||||||||||||
| pytest.fail( | ||||||||||||||||
| f"Background tasks raised {len(background_task_exceptions)} exception(s):\n" | ||||||||||||||||
| + "\n\n".join(f"{type(e).__name__}: {e}" for e in background_task_exceptions) | ||||||||||||||||
| ) | ||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||
| import logging | ||||||||||||||||||||||
| import traceback | ||||||||||||||||||||||
| from collections import deque | ||||||||||||||||||||||
| from weakref import WeakSet | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||
| background_tasks_set: WeakSet[asyncio.Task[None]] = WeakSet() | ||||||||||||||||||||||
| background_task_exceptions: deque[Exception] = deque( | ||||||||||||||||||||||
| maxlen=100 # don't grow infinitely in production | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
Comment on lines
+9
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider making the exception buffer size configurable. The Example: +import os
+
+MAX_EXCEPTIONS = int(os.getenv("MAX_BACKGROUND_EXCEPTIONS", "100"))
+
background_task_exceptions: deque[Exception] = deque(
- maxlen=100 # don't grow infinitely in production
+ maxlen=MAX_EXCEPTIONS # don't grow infinitely in production
)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| # Store creation tracebacks for debugging | ||||||||||||||||||||||
| _task_creation_tracebacks: dict[int, str] = {} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def _task_done_callback(task: asyncio.Task[None]): | ||||||||||||||||||||||
| task_id = id(task) | ||||||||||||||||||||||
| background_tasks_set.discard(task) | ||||||||||||||||||||||
| try: | ||||||||||||||||||||||
| task.result() | ||||||||||||||||||||||
| except ( # pragma: no cover # hard to unit test this, but it'd be good to think of a way to do so | ||||||||||||||||||||||
| asyncio.CancelledError | ||||||||||||||||||||||
| ): | ||||||||||||||||||||||
| _ = _task_creation_tracebacks.pop(task_id, None) | ||||||||||||||||||||||
| return | ||||||||||||||||||||||
| except Exception as e: # pragma: no cover # hard to unit test this, but it'd be good to think of a way to do so | ||||||||||||||||||||||
| creation_tb = _task_creation_tracebacks.pop(task_id, "No traceback available") | ||||||||||||||||||||||
| logger.exception(f"Unhandled exception in background task\nTask was created from:\n{creation_tb}") | ||||||||||||||||||||||
| background_task_exceptions.append(e) | ||||||||||||||||||||||
|
Comment on lines
+21
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Testing suggestion for exception handlers. The Example test: async def test_background_task_exception_captured():
async def failing_task():
raise ValueError("test error")
task = asyncio.create_task(failing_task())
register_task(task)
await asyncio.sleep(0.1) # Let task complete
assert len(background_task_exceptions) == 1
assert isinstance(background_task_exceptions[0], ValueError) |
||||||||||||||||||||||
| else: | ||||||||||||||||||||||
| # Clean up on successful completion | ||||||||||||||||||||||
| _ = _task_creation_tracebacks.pop(task_id, None) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| def register_task(task: asyncio.Task[None]) -> None: | ||||||||||||||||||||||
| # Capture the stack trace at task creation time (excluding this function) | ||||||||||||||||||||||
| creation_stack = "".join(traceback.format_stack()[:-1]) | ||||||||||||||||||||||
| _task_creation_tracebacks[id(task)] = creation_stack | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| background_tasks_set.add(task) | ||||||||||||||||||||||
| task.add_done_callback(_task_done_callback) | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,11 +5,16 @@ import pytest | |||||||||||||||||
| from pydantic import JsonValue | ||||||||||||||||||
| from vcr import VCR | ||||||||||||||||||
|
|
||||||||||||||||||
| ALLOWED_HOSTS = ["testserver"] # Skip recording any requests to our own server - let them run live | ||||||||||||||||||
| UNREACHABLE_IP_ADDRESS = "192.0.2.1" # RFC 5737 TEST-NET-1 | ||||||||||||||||||
| IGNORED_HOSTS = [ | ||||||||||||||||||
| "testserver", # Skip recording any requests to our own server - let them run live | ||||||||||||||||||
| UNREACHABLE_IP_ADDRESS, # allow this through VCR in order to be able to test network failure handling | ||||||||||||||||||
| ] | ||||||||||||||||||
| ALLOWED_HOSTS: list[str] = [] | ||||||||||||||||||
|
|
||||||||||||||||||
| CUSTOM_ALLOWED_HOSTS: tuple[str, ...] = () | ||||||||||||||||||
| CUSTOM_IGNORED_HOSTS: tuple[str, ...] = () | ||||||||||||||||||
|
|
||||||||||||||||||
| ALLOWED_HOSTS.extend(CUSTOM_ALLOWED_HOSTS) | ||||||||||||||||||
| IGNORED_HOSTS.extend(CUSTOM_IGNORED_HOSTS) | ||||||||||||||||||
|
Comment on lines
+15
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against accidental string “custom hosts” input (would extend by characters). If a user edits CUSTOM_IGNORED_HOSTS: tuple[str, ...] = ()
+assert not isinstance(CUSTOM_IGNORED_HOSTS, str), "CUSTOM_IGNORED_HOSTS must be a tuple[str, ...], not a str"
IGNORED_HOSTS.extend(CUSTOM_IGNORED_HOSTS)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| if ( | ||||||||||||||||||
| os.name == "nt" | ||||||||||||||||||
| ): # on Windows (in CI), the network calls happen at a lower level socket connection even to our FastAPI test client, and can get automatically blocked. This disables that automatic network guard, which isn't great...but since it's still in place on Linux, any actual problems would hopefully get caught before pushing to CI. | ||||||||||||||||||
|
|
@@ -18,12 +23,18 @@ if ( | |||||||||||||||||
|
|
||||||||||||||||||
| @pytest.fixture(autouse=True) | ||||||||||||||||||
| def vcr_config() -> dict[str, list[str]]: | ||||||||||||||||||
| return {"allowed_hosts": ALLOWED_HOSTS, "filter_headers": ["User-Agent"]} | ||||||||||||||||||
| cfg: dict[str, list[str]] = { | ||||||||||||||||||
| "ignore_hosts": IGNORED_HOSTS, | ||||||||||||||||||
| "filter_headers": ["User-Agent"], | ||||||||||||||||||
| } | ||||||||||||||||||
| if ALLOWED_HOSTS: | ||||||||||||||||||
| cfg["allowed_hosts"] = ALLOWED_HOSTS | ||||||||||||||||||
| return cfg | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| def pytest_recording_configure( | ||||||||||||||||||
| vcr: VCR, | ||||||||||||||||||
| config: pytest.Config, # noqa: ARG001 # the config argument MUST be present (even when unused) or pytest-recording throws an error | ||||||||||||||||||
| vcr: VCR, | ||||||||||||||||||
| ): | ||||||||||||||||||
| vcr.match_on = cast(tuple[str, ...], vcr.match_on) # pyright: ignore[reportUnknownMemberType] # I know vcr.match_on is unknown, that's why I'm casting and isinstance-ing it...not sure if there's a different approach pyright prefers | ||||||||||||||||||
| assert isinstance(vcr.match_on, tuple), ( | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider making the timeout configurable.
The 5-second timeout is hardcoded, which might be too short for slow CI environments or unnecessarily long for local development. Consider making it configurable via an environment variable or fixture parameter.
Example:
📝 Committable suggestion