apitally 0.16.1__tar.gz → 0.16.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/tests.yaml +1 -18
  2. {apitally-0.16.1 → apitally-0.16.3}/.pre-commit-config.yaml +2 -2
  3. {apitally-0.16.1 → apitally-0.16.3}/PKG-INFO +4 -4
  4. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_asyncio.py +1 -7
  5. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_threading.py +1 -3
  6. {apitally-0.16.1 → apitally-0.16.3}/apitally/starlette.py +40 -20
  7. {apitally-0.16.1 → apitally-0.16.3}/pyproject.toml +2 -2
  8. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_asyncio.py +3 -2
  9. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_threading.py +3 -1
  10. {apitally-0.16.1 → apitally-0.16.3}/tests/test_fastapi.py +0 -2
  11. {apitally-0.16.1 → apitally-0.16.3}/tests/test_litestar.py +0 -1
  12. {apitally-0.16.1 → apitally-0.16.3}/tests/test_starlette.py +103 -104
  13. {apitally-0.16.1 → apitally-0.16.3}/uv.lock +1574 -1574
  14. {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/publish.yaml +0 -0
  15. {apitally-0.16.1 → apitally-0.16.3}/.github/workflows/summary.yaml +0 -0
  16. {apitally-0.16.1 → apitally-0.16.3}/.gitignore +0 -0
  17. {apitally-0.16.1 → apitally-0.16.3}/LICENSE +0 -0
  18. {apitally-0.16.1 → apitally-0.16.3}/Makefile +0 -0
  19. {apitally-0.16.1 → apitally-0.16.3}/README.md +0 -0
  20. {apitally-0.16.1 → apitally-0.16.3}/apitally/__init__.py +0 -0
  21. {apitally-0.16.1 → apitally-0.16.3}/apitally/blacksheep.py +0 -0
  22. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/__init__.py +0 -0
  23. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/client_base.py +0 -0
  24. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/consumers.py +0 -0
  25. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/logging.py +0 -0
  26. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/request_logging.py +0 -0
  27. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/requests.py +0 -0
  28. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/sentry.py +0 -0
  29. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/server_errors.py +0 -0
  30. {apitally-0.16.1 → apitally-0.16.3}/apitally/client/validation_errors.py +0 -0
  31. {apitally-0.16.1 → apitally-0.16.3}/apitally/common.py +0 -0
  32. {apitally-0.16.1 → apitally-0.16.3}/apitally/django.py +1 -1
  33. {apitally-0.16.1 → apitally-0.16.3}/apitally/django_ninja.py +0 -0
  34. {apitally-0.16.1 → apitally-0.16.3}/apitally/django_rest_framework.py +0 -0
  35. {apitally-0.16.1 → apitally-0.16.3}/apitally/fastapi.py +0 -0
  36. {apitally-0.16.1 → apitally-0.16.3}/apitally/flask.py +1 -1
  37. {apitally-0.16.1 → apitally-0.16.3}/apitally/litestar.py +0 -0
  38. {apitally-0.16.1 → apitally-0.16.3}/apitally/py.typed +0 -0
  39. {apitally-0.16.1 → apitally-0.16.3}/renovate.json +0 -0
  40. {apitally-0.16.1 → apitally-0.16.3}/tests/__init__.py +0 -0
  41. {apitally-0.16.1 → apitally-0.16.3}/tests/conftest.py +0 -0
  42. {apitally-0.16.1 → apitally-0.16.3}/tests/constants.py +0 -0
  43. {apitally-0.16.1 → apitally-0.16.3}/tests/django_ninja_urls.py +0 -0
  44. {apitally-0.16.1 → apitally-0.16.3}/tests/django_rest_framework_urls.py +0 -0
  45. {apitally-0.16.1 → apitally-0.16.3}/tests/test_blacksheep.py +0 -0
  46. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_consumers.py +0 -0
  47. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_request_logging.py +0 -0
  48. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_requests.py +0 -0
  49. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_server_errors.py +0 -0
  50. {apitally-0.16.1 → apitally-0.16.3}/tests/test_client_validation_errors.py +0 -0
  51. {apitally-0.16.1 → apitally-0.16.3}/tests/test_django_ninja.py +0 -0
  52. {apitally-0.16.1 → apitally-0.16.3}/tests/test_django_rest_framework.py +0 -0
  53. {apitally-0.16.1 → apitally-0.16.3}/tests/test_flask.py +0 -0
@@ -48,11 +48,8 @@ jobs:
48
48
  matrix:
49
49
  python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
50
50
  deps:
51
- - starlette
52
51
  - fastapi starlette
53
- - fastapi==0.109.0 starlette
54
- - fastapi==0.100.1 starlette
55
- - fastapi==0.88.0 starlette
52
+ - fastapi==0.94.1 starlette
56
53
  - flask
57
54
  - flask==2.3.*
58
55
  - flask==2.0.3 Werkzeug==2.*
@@ -61,36 +58,22 @@ jobs:
61
58
  - djangorestframework==3.12.* django==3.2.* uritemplate
62
59
  - djangorestframework==3.10.* django==2.2.* uritemplate
63
60
  - django-ninja django
64
- - django-ninja==0.22.* django
65
61
  - django-ninja==0.18.0 django
66
62
  - litestar
67
- - litestar==2.6.1
68
- - litestar==2.3.0
69
63
  - litestar==2.0.1
70
64
  - blacksheep
71
- - blacksheep==2.2.0
72
65
  - blacksheep==2.1.0
73
66
  exclude:
74
67
  - python: "3.8"
75
68
  deps: blacksheep
76
- - python: "3.8"
77
- deps: blacksheep==2.2.0
78
69
  - python: "3.8"
79
70
  deps: blacksheep==2.1.0
80
- - python: "3.12"
81
- deps: fastapi==0.100.1 starlette
82
- - python: "3.12"
83
- deps: fastapi==0.87.0 starlette
84
71
  - python: "3.12"
85
72
  deps: djangorestframework==3.12.* django==3.2.* uritemplate
86
73
  - python: "3.12"
87
74
  deps: djangorestframework==3.10.* django==2.2.* uritemplate
88
75
  - python: "3.12"
89
76
  deps: litestar==2.0.1
90
- - python: "3.13"
91
- deps: fastapi==0.100.1 starlette
92
- - python: "3.13"
93
- deps: fastapi==0.87.0 starlette
94
77
  - python: "3.13"
95
78
  deps: djangorestframework==3.12.* django==3.2.* uritemplate
96
79
  - python: "3.13"
@@ -8,12 +8,12 @@ repos:
8
8
  - id: trailing-whitespace
9
9
  - id: mixed-line-ending
10
10
  - repo: https://github.com/charliermarsh/ruff-pre-commit
11
- rev: v0.11.7
11
+ rev: v0.11.8
12
12
  hooks:
13
13
  - id: ruff
14
14
  args: ["--fix", "--exit-non-zero-on-fix"]
15
15
  - id: ruff-format
16
16
  - repo: https://github.com/astral-sh/uv-pre-commit
17
- rev: 0.6.17
17
+ rev: 0.7.2
18
18
  hooks:
19
19
  - id: uv-lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.16.1
3
+ Version: 0.16.3
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette, Litestar and BlackSheep.
5
5
  Project-URL: Homepage, https://apitally.io
6
6
  Project-URL: Documentation, https://docs.apitally.io
@@ -49,9 +49,9 @@ Requires-Dist: inflection>=0.5.1; extra == 'django-rest-framework'
49
49
  Requires-Dist: requests>=2.26.0; extra == 'django-rest-framework'
50
50
  Requires-Dist: uritemplate>=3.0.0; extra == 'django-rest-framework'
51
51
  Provides-Extra: fastapi
52
- Requires-Dist: fastapi>=0.88.0; extra == 'fastapi'
52
+ Requires-Dist: fastapi>=0.94.1; extra == 'fastapi'
53
53
  Requires-Dist: httpx>=0.22.0; extra == 'fastapi'
54
- Requires-Dist: starlette<1.0.0,>=0.22.0; extra == 'fastapi'
54
+ Requires-Dist: starlette<1.0.0,>=0.26.1; extra == 'fastapi'
55
55
  Provides-Extra: flask
56
56
  Requires-Dist: flask>=2.0.0; extra == 'flask'
57
57
  Requires-Dist: requests>=2.26.0; extra == 'flask'
@@ -62,7 +62,7 @@ Provides-Extra: sentry
62
62
  Requires-Dist: sentry-sdk>=2.2.0; extra == 'sentry'
63
63
  Provides-Extra: starlette
64
64
  Requires-Dist: httpx>=0.22.0; extra == 'starlette'
65
- Requires-Dist: starlette<1.0.0,>=0.21.0; extra == 'starlette'
65
+ Requires-Dist: starlette<1.0.0,>=0.26.1; extra == 'starlette'
66
66
  Description-Content-Type: text/markdown
67
67
 
68
68
  <p align="center">
@@ -41,7 +41,6 @@ class ApitallyClient(ApitallyClientBase):
41
41
  self._stop_sync_loop = False
42
42
  self._sync_loop_task: Optional[asyncio.Task] = None
43
43
  self._sync_data_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
44
- self._set_startup_data_task: Optional[asyncio.Task] = None
45
44
 
46
45
  def get_http_client(self) -> httpx.AsyncClient:
47
46
  if httpx.__version__ >= "0.26.0":
@@ -67,7 +66,7 @@ class ApitallyClient(ApitallyClientBase):
67
66
  try:
68
67
  async with self.get_http_client() as client:
69
68
  tasks = [self.send_sync_data(client), self.send_log_data(client)]
70
- if not self._startup_data_sent and last_sync_time > 0: # not on first sync
69
+ if not self._startup_data_sent:
71
70
  tasks.append(self.send_startup_data(client))
72
71
  await asyncio.gather(*tasks)
73
72
  last_sync_time = now
@@ -92,11 +91,6 @@ class ApitallyClient(ApitallyClientBase):
92
91
  def set_startup_data(self, data: Dict[str, Any]) -> None:
93
92
  self._startup_data_sent = False
94
93
  self._startup_data = self.add_uuids_to_data(data)
95
- self._set_startup_data_task = asyncio.create_task(self._set_startup_data())
96
-
97
- async def _set_startup_data(self) -> None:
98
- async with self.get_http_client() as client:
99
- await self.send_startup_data(client)
100
94
 
101
95
  async def send_startup_data(self, client: httpx.AsyncClient) -> None:
102
96
  if self._startup_data is not None:
@@ -80,7 +80,7 @@ class ApitallyClient(ApitallyClientBase):
80
80
  if (now - last_sync_time) >= self.sync_interval:
81
81
  try:
82
82
  with requests.Session() as session:
83
- if not self._startup_data_sent and last_sync_time > 0: # not on first sync
83
+ if not self._startup_data_sent:
84
84
  self.send_startup_data(session)
85
85
  self.send_sync_data(session)
86
86
  self.send_log_data(session)
@@ -105,8 +105,6 @@ class ApitallyClient(ApitallyClientBase):
105
105
  def set_startup_data(self, data: Dict[str, Any]) -> None:
106
106
  self._startup_data_sent = False
107
107
  self._startup_data = self.add_uuids_to_data(data)
108
- with requests.Session() as session:
109
- self.send_startup_data(session)
110
108
 
111
109
  def send_startup_data(self, session: requests.Session) -> None:
112
110
  if self._startup_data is not None:
@@ -1,17 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import time
5
- from typing import Any, Callable, Dict, List, Optional, Union
4
+ from contextlib import asynccontextmanager
5
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
6
6
  from warnings import warn
7
7
 
8
8
  from httpx import HTTPStatusError, Proxy
9
+ from starlette.applications import Starlette
9
10
  from starlette.datastructures import Headers
10
11
  from starlette.requests import Request
11
12
  from starlette.routing import BaseRoute, Match, Router
12
13
  from starlette.schemas import EndpointInfo, SchemaGenerator
13
14
  from starlette.testclient import TestClient
14
- from starlette.types import ASGIApp, Message, Receive, Scope, Send
15
+ from starlette.types import ASGIApp, Lifespan, Message, Receive, Scope, Send
15
16
 
16
17
  from apitally.client.client_asyncio import ApitallyClient
17
18
  from apitally.client.consumers import Consumer as ApitallyConsumer
@@ -40,6 +41,8 @@ class ApitallyMiddleware:
40
41
  proxy: Optional[Union[str, Proxy]] = None,
41
42
  ) -> None:
42
43
  self.app = app
44
+ self.app_version = app_version
45
+ self.openapi_url = openapi_url
43
46
  self.identify_consumer_callback = identify_consumer_callback
44
47
  self.client = ApitallyClient(
45
48
  client_id=client_id,
@@ -47,10 +50,6 @@ class ApitallyMiddleware:
47
50
  request_logging_config=request_logging_config,
48
51
  proxy=proxy,
49
52
  )
50
- self.client.start_sync_loop()
51
- self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
52
- self.delayed_set_startup_data(app_version, openapi_url)
53
- _register_shutdown_handler(app, self.client.handle_shutdown)
54
53
 
55
54
  self.capture_request_body = (
56
55
  self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
@@ -59,17 +58,16 @@ class ApitallyMiddleware:
59
58
  self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
60
59
  )
61
60
 
62
- def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
63
- self._delayed_set_startup_data_task = asyncio.create_task(
64
- self._delayed_set_startup_data(app_version, openapi_url)
61
+ _inject_lifespan_handlers(
62
+ app,
63
+ on_startup=self.on_startup,
64
+ on_shutdown=self.client.handle_shutdown,
65
65
  )
66
66
 
67
- async def _delayed_set_startup_data(
68
- self, app_version: Optional[str] = None, openapi_url: Optional[str] = None
69
- ) -> None:
70
- await asyncio.sleep(1.0) # Short delay to allow app routes to be registered first
71
- data = _get_startup_data(self.app, app_version, openapi_url)
67
+ async def on_startup(self) -> None:
68
+ data = _get_startup_data(self.app, app_version=self.app_version, openapi_url=self.openapi_url)
72
69
  self.client.set_startup_data(data)
70
+ self.client.start_sync_loop()
73
71
 
74
72
  async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
75
73
  if self.client.enabled and scope["type"] == "http" and scope["method"] != "OPTIONS":
@@ -296,8 +294,30 @@ def _get_routes(app: Union[ASGIApp, Router]) -> List[BaseRoute]:
296
294
  return [] # pragma: no cover
297
295
 
298
296
 
299
- def _register_shutdown_handler(app: Union[ASGIApp, Router], shutdown_handler: Callable[[], Any]) -> None:
300
- if isinstance(app, Router):
301
- app.add_event_handler("shutdown", shutdown_handler)
302
- elif hasattr(app, "app"):
303
- _register_shutdown_handler(app.app, shutdown_handler)
297
+ def _inject_lifespan_handlers(
298
+ app: Union[ASGIApp, Router],
299
+ on_startup: Callable[[], Awaitable[Any]],
300
+ on_shutdown: Callable[[], Awaitable[Any]],
301
+ ) -> None:
302
+ """
303
+ Ensures the given startup and shutdown functions are called as part of the app's lifespan context manager.
304
+ """
305
+ router = app
306
+ while not isinstance(router, Router) and hasattr(router, "app"):
307
+ router = router.app
308
+ if not isinstance(router, Router):
309
+ raise TypeError("app must be a Starlette or Router instance")
310
+
311
+ lifespan: Optional[Lifespan] = getattr(router, "lifespan_context", None)
312
+
313
+ @asynccontextmanager
314
+ async def wrapped_lifespan(app: Starlette):
315
+ await on_startup()
316
+ if lifespan is not None:
317
+ async with lifespan(app):
318
+ yield
319
+ else:
320
+ yield
321
+ await on_shutdown()
322
+
323
+ router.lifespan_context = wrapped_lifespan
@@ -49,11 +49,11 @@ django_rest_framework = [
49
49
  "inflection>=0.5.1", # required for schema generation
50
50
  "requests>=2.26.0",
51
51
  ]
52
- fastapi = ["fastapi>=0.88.0", "starlette>=0.22.0,<1.0.0", "httpx>=0.22.0"]
52
+ fastapi = ["fastapi>=0.94.1", "starlette>=0.26.1,<1.0.0", "httpx>=0.22.0"]
53
53
  flask = ["flask>=2.0.0", "requests>=2.26.0"]
54
54
  litestar = ["litestar>=2.0.0", "httpx>=0.22.0"]
55
55
  sentry = ["sentry-sdk>=2.2.0"]
56
- starlette = ["starlette>=0.21.0,<1.0.0", "httpx>=0.22.0"]
56
+ starlette = ["starlette>=0.26.1,<1.0.0", "httpx>=0.22.0"]
57
57
 
58
58
  [project.urls]
59
59
  Homepage = "https://apitally.io"
@@ -156,13 +156,14 @@ async def test_send_log_data(client: ApitallyClient, httpx_mock: HTTPXMock):
156
156
  assert len(client.request_logger.file_deque) == 0
157
157
 
158
158
 
159
- async def test_set_startup_data(client: ApitallyClient, httpx_mock: HTTPXMock):
159
+ async def test_send_startup_data(client: ApitallyClient, httpx_mock: HTTPXMock):
160
160
  from apitally.client.client_base import HUB_BASE_URL, HUB_VERSION
161
161
 
162
162
  httpx_mock.add_response()
163
163
  data = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"}
164
164
  client.set_startup_data(data)
165
- await asyncio.sleep(0.01)
165
+ async with client.get_http_client() as http_client:
166
+ await client.send_startup_data(client=http_client)
166
167
 
167
168
  request = httpx_mock.get_request(url=f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/startup")
168
169
  assert request is not None
@@ -141,12 +141,14 @@ def test_send_log_data(client: ApitallyClient, requests_mock: Mocker):
141
141
  assert len(client.request_logger.file_deque) == 0
142
142
 
143
143
 
144
- def test_set_startup_data(client: ApitallyClient, requests_mock: Mocker):
144
+ def test_send_startup_data(client: ApitallyClient, requests_mock: Mocker):
145
145
  from apitally.client.client_base import HUB_BASE_URL, HUB_VERSION
146
146
 
147
147
  mock = requests_mock.register_uri("POST", f"{HUB_BASE_URL}/{HUB_VERSION}/{CLIENT_ID}/{ENV}/startup")
148
148
  data = {"paths": [], "client_version": "1.0.0", "starlette_version": "0.28.0", "python_version": "3.11.4"}
149
149
  client.set_startup_data(data)
150
+ with requests.Session() as session:
151
+ client.send_startup_data(session)
150
152
 
151
153
  assert len(mock.request_history) == 1
152
154
  request_data = mock.request_history[0].json()
@@ -30,8 +30,6 @@ def app(module_mocker: MockerFixture) -> FastAPI:
30
30
 
31
31
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
32
32
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
33
- module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.set_startup_data")
34
- module_mocker.patch("apitally.starlette.ApitallyMiddleware.delayed_set_startup_data")
35
33
 
36
34
  def identify_consumer(request: Request) -> Optional[str]:
37
35
  if consumer := request.query_params.get("consumer"):
@@ -37,7 +37,6 @@ async def app(module_mocker: MockerFixture) -> Litestar:
37
37
 
38
38
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
39
39
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
40
- module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.set_startup_data")
41
40
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.handle_shutdown", mocked_handle_shutdown)
42
41
 
43
42
  @get("/foo")
@@ -28,8 +28,7 @@ if TYPE_CHECKING:
28
28
  async def app(request: FixtureRequest, module_mocker: MockerFixture) -> Starlette:
29
29
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient._instance", None)
30
30
  module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.start_sync_loop")
31
- module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.set_startup_data")
32
- module_mocker.patch("apitally.starlette.ApitallyMiddleware.delayed_set_startup_data")
31
+ module_mocker.patch("apitally.client.client_asyncio.ApitallyClient.handle_shutdown")
33
32
  if request.param == "starlette":
34
33
  return get_starlette_app()
35
34
  elif request.param == "fastapi":
@@ -184,35 +183,35 @@ def test_middleware_requests_ok(app: Starlette, mocker: MockerFixture):
184
183
  from starlette.testclient import TestClient
185
184
 
186
185
  mock = mocker.patch("apitally.client.requests.RequestCounter.add_request")
187
- client = TestClient(app)
188
-
189
- response = client.get("/api/foo")
190
- assert response.status_code == 200
191
- mock.assert_called_once()
192
- assert mock.call_args is not None
193
- assert mock.call_args.kwargs["consumer"] == "test"
194
- assert mock.call_args.kwargs["method"] == "GET"
195
- assert mock.call_args.kwargs["path"] == "/api/foo"
196
- assert mock.call_args.kwargs["status_code"] == 200
197
- assert mock.call_args.kwargs["response_time"] > 0
198
-
199
- response = client.get("/api/foo/123")
200
- assert response.status_code == 200
201
- assert mock.call_count == 2
202
- assert mock.call_args is not None
203
- assert mock.call_args.kwargs["path"] == "/api/foo/{bar}"
204
-
205
- response = client.post("/api/bar")
206
- assert response.status_code == 200
207
- assert mock.call_count == 3
208
- assert mock.call_args is not None
209
- assert mock.call_args.kwargs["method"] == "POST"
210
-
211
- response = client.get("/stream")
212
- assert response.status_code == 200
213
- assert mock.call_count == 4
214
- assert mock.call_args is not None
215
- assert mock.call_args.kwargs["response_size"] == 6
186
+
187
+ with TestClient(app) as client:
188
+ response = client.get("/api/foo")
189
+ assert response.status_code == 200
190
+ mock.assert_called_once()
191
+ assert mock.call_args is not None
192
+ assert mock.call_args.kwargs["consumer"] == "test"
193
+ assert mock.call_args.kwargs["method"] == "GET"
194
+ assert mock.call_args.kwargs["path"] == "/api/foo"
195
+ assert mock.call_args.kwargs["status_code"] == 200
196
+ assert mock.call_args.kwargs["response_time"] > 0
197
+
198
+ response = client.get("/api/foo/123")
199
+ assert response.status_code == 200
200
+ assert mock.call_count == 2
201
+ assert mock.call_args is not None
202
+ assert mock.call_args.kwargs["path"] == "/api/foo/{bar}"
203
+
204
+ response = client.post("/api/bar")
205
+ assert response.status_code == 200
206
+ assert mock.call_count == 3
207
+ assert mock.call_args is not None
208
+ assert mock.call_args.kwargs["method"] == "POST"
209
+
210
+ response = client.get("/stream")
211
+ assert response.status_code == 200
212
+ assert mock.call_count == 4
213
+ assert mock.call_args is not None
214
+ assert mock.call_args.kwargs["response_size"] == 6
216
215
 
217
216
 
218
217
  def test_middleware_requests_error(app: Starlette, mocker: MockerFixture):
@@ -220,60 +219,60 @@ def test_middleware_requests_error(app: Starlette, mocker: MockerFixture):
220
219
 
221
220
  mock1 = mocker.patch("apitally.client.requests.RequestCounter.add_request")
222
221
  mock2 = mocker.patch("apitally.client.server_errors.ServerErrorCounter.add_server_error")
223
- client = TestClient(app, raise_server_exceptions=False)
224
-
225
- response = client.post("/api/baz")
226
- assert response.status_code == 500
227
- mock1.assert_called_once()
228
- assert mock1.call_args is not None
229
- assert mock1.call_args.kwargs["method"] == "POST"
230
- assert mock1.call_args.kwargs["path"] == "/api/baz"
231
- assert mock1.call_args.kwargs["status_code"] == 500
232
- assert mock1.call_args.kwargs["response_time"] > 0
233
-
234
- mock2.assert_called_once()
235
- assert mock2.call_args is not None
236
- exception = mock2.call_args.kwargs["exception"]
237
- assert isinstance(exception, ValueError)
238
-
239
- # Throws a ValueError in a background task, but returns 200
240
- response = client.post("/test/task")
241
- assert response.status_code == 200
242
- assert mock1.call_count == 2
243
- assert mock1.call_args is not None
244
- assert mock1.call_args.kwargs["status_code"] == 200
245
- mock2.assert_called_once() # Not called again
222
+
223
+ with TestClient(app, raise_server_exceptions=False) as client:
224
+ response = client.post("/api/baz")
225
+ assert response.status_code == 500
226
+ mock1.assert_called_once()
227
+ assert mock1.call_args is not None
228
+ assert mock1.call_args.kwargs["method"] == "POST"
229
+ assert mock1.call_args.kwargs["path"] == "/api/baz"
230
+ assert mock1.call_args.kwargs["status_code"] == 500
231
+ assert mock1.call_args.kwargs["response_time"] > 0
232
+
233
+ mock2.assert_called_once()
234
+ assert mock2.call_args is not None
235
+ exception = mock2.call_args.kwargs["exception"]
236
+ assert isinstance(exception, ValueError)
237
+
238
+ # Throws a ValueError in a background task, but returns 200
239
+ response = client.post("/test/task")
240
+ assert response.status_code == 200
241
+ assert mock1.call_count == 2
242
+ assert mock1.call_args is not None
243
+ assert mock1.call_args.kwargs["status_code"] == 200
244
+ mock2.assert_called_once() # Not called again
246
245
 
247
246
 
248
247
  def test_middleware_requests_unhandled(app: Starlette, mocker: MockerFixture):
249
248
  from starlette.testclient import TestClient
250
249
 
251
250
  mock = mocker.patch("apitally.client.requests.RequestCounter.add_request")
252
- client = TestClient(app)
253
251
 
254
- response = client.post("/xxx")
255
- assert response.status_code == 404
256
- mock.assert_not_called()
252
+ with TestClient(app) as client:
253
+ response = client.post("/xxx")
254
+ assert response.status_code == 404
255
+ mock.assert_not_called()
257
256
 
258
257
 
259
258
  def test_middleware_validation_error(app: Starlette, mocker: MockerFixture):
260
259
  from starlette.testclient import TestClient
261
260
 
262
261
  mock = mocker.patch("apitally.client.validation_errors.ValidationErrorCounter.add_validation_errors")
263
- client = TestClient(app)
264
262
 
265
- # Validation error as foo must be an integer
266
- response = client.get("/api/val?foo=bar")
267
- assert response.status_code == 422
263
+ with TestClient(app) as client:
264
+ # Validation error as foo must be an integer
265
+ response = client.get("/api/val?foo=bar")
266
+ assert response.status_code == 422
268
267
 
269
- # FastAPI only
270
- if response.headers["Content-Type"] == "application/json":
271
- mock.assert_called_once()
272
- assert mock.call_args is not None
273
- assert mock.call_args.kwargs["method"] == "GET"
274
- assert mock.call_args.kwargs["path"] == "/api/val"
275
- assert len(mock.call_args.kwargs["detail"]) == 1
276
- assert mock.call_args.kwargs["detail"][0]["loc"] == ["query", "foo"]
268
+ # FastAPI only
269
+ if response.headers["Content-Type"] == "application/json":
270
+ mock.assert_called_once()
271
+ assert mock.call_args is not None
272
+ assert mock.call_args.kwargs["method"] == "GET"
273
+ assert mock.call_args.kwargs["path"] == "/api/val"
274
+ assert len(mock.call_args.kwargs["detail"]) == 1
275
+ assert mock.call_args.kwargs["detail"][0]["loc"] == ["query", "foo"]
277
276
 
278
277
 
279
278
  def test_middleware_request_logging(app: Starlette, mocker: MockerFixture):
@@ -282,40 +281,40 @@ def test_middleware_request_logging(app: Starlette, mocker: MockerFixture):
282
281
  from apitally.client.request_logging import BODY_TOO_LARGE
283
282
 
284
283
  mock = mocker.patch("apitally.client.request_logging.RequestLogger.log_request")
285
- client = TestClient(app)
286
-
287
- response = client.get("/api/foo/123?foo=bar", headers={"Test-Header": "test"})
288
- assert response.status_code == 200
289
- mock.assert_called_once()
290
- assert mock.call_args is not None
291
- assert mock.call_args.kwargs["request"]["method"] == "GET"
292
- assert mock.call_args.kwargs["request"]["path"] == "/api/foo/{bar}"
293
- assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/foo/123?foo=bar"
294
- assert ("test-header", "test") in mock.call_args.kwargs["request"]["headers"]
295
- assert mock.call_args.kwargs["request"]["consumer"] == "test"
296
- assert mock.call_args.kwargs["response"]["status_code"] == 200
297
- assert mock.call_args.kwargs["response"]["response_time"] > 0
298
- assert ("content-type", "text/plain; charset=utf-8") in mock.call_args.kwargs["response"]["headers"]
299
- assert mock.call_args.kwargs["response"]["size"] > 0
300
- assert mock.call_args.kwargs["response"]["body"] == b"foo: 123"
301
-
302
- response = client.post("/api/bar", content=b"foo")
303
- assert response.status_code == 200
304
- assert mock.call_count == 2
305
- assert mock.call_args is not None
306
- assert mock.call_args.kwargs["request"]["method"] == "POST"
307
- assert mock.call_args.kwargs["request"]["path"] == "/api/bar"
308
- assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/bar"
309
- assert mock.call_args.kwargs["request"]["body"] == b"foo"
310
- assert mock.call_args.kwargs["response"]["body"] == b"bar: foo"
311
-
312
- mocker.patch("apitally.starlette.MAX_BODY_SIZE", 2)
313
- response = client.post("/api/bar", content=b"foo")
314
- assert response.status_code == 200
315
- assert mock.call_count == 3
316
- assert mock.call_args is not None
317
- assert mock.call_args.kwargs["request"]["body"] == BODY_TOO_LARGE
318
- assert mock.call_args.kwargs["response"]["body"] == BODY_TOO_LARGE
284
+
285
+ with TestClient(app) as client:
286
+ response = client.get("/api/foo/123?foo=bar", headers={"Test-Header": "test"})
287
+ assert response.status_code == 200
288
+ mock.assert_called_once()
289
+ assert mock.call_args is not None
290
+ assert mock.call_args.kwargs["request"]["method"] == "GET"
291
+ assert mock.call_args.kwargs["request"]["path"] == "/api/foo/{bar}"
292
+ assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/foo/123?foo=bar"
293
+ assert ("test-header", "test") in mock.call_args.kwargs["request"]["headers"]
294
+ assert mock.call_args.kwargs["request"]["consumer"] == "test"
295
+ assert mock.call_args.kwargs["response"]["status_code"] == 200
296
+ assert mock.call_args.kwargs["response"]["response_time"] > 0
297
+ assert ("content-type", "text/plain; charset=utf-8") in mock.call_args.kwargs["response"]["headers"]
298
+ assert mock.call_args.kwargs["response"]["size"] > 0
299
+ assert mock.call_args.kwargs["response"]["body"] == b"foo: 123"
300
+
301
+ response = client.post("/api/bar", content=b"foo")
302
+ assert response.status_code == 200
303
+ assert mock.call_count == 2
304
+ assert mock.call_args is not None
305
+ assert mock.call_args.kwargs["request"]["method"] == "POST"
306
+ assert mock.call_args.kwargs["request"]["path"] == "/api/bar"
307
+ assert mock.call_args.kwargs["request"]["url"] == "http://testserver/api/bar"
308
+ assert mock.call_args.kwargs["request"]["body"] == b"foo"
309
+ assert mock.call_args.kwargs["response"]["body"] == b"bar: foo"
310
+
311
+ mocker.patch("apitally.starlette.MAX_BODY_SIZE", 2)
312
+ response = client.post("/api/bar", content=b"foo")
313
+ assert response.status_code == 200
314
+ assert mock.call_count == 3
315
+ assert mock.call_args is not None
316
+ assert mock.call_args.kwargs["request"]["body"] == BODY_TOO_LARGE
317
+ assert mock.call_args.kwargs["response"]["body"] == BODY_TOO_LARGE
319
318
 
320
319
 
321
320
  def test_get_startup_data(app: Starlette, mocker: MockerFixture):