apitally 0.16.2__py3-none-any.whl → 0.17.0__py3-none-any.whl

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.
apitally/django.py CHANGED
@@ -10,8 +10,10 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Unio
10
10
  from warnings import warn
11
11
 
12
12
  from django.conf import settings
13
+ from django.contrib.admindocs.views import extract_views_from_urlpatterns, simplify_regex
13
14
  from django.urls import URLPattern, URLResolver, get_resolver
14
15
  from django.utils.module_loading import import_string
16
+ from django.views.generic.base import View
15
17
 
16
18
  from apitally.client.client_threading import ApitallyClient
17
19
  from apitally.client.consumers import Consumer as ApitallyConsumer
@@ -41,6 +43,7 @@ class ApitallyMiddlewareConfig:
41
43
  request_logging_config: Optional[RequestLoggingConfig]
42
44
  app_version: Optional[str]
43
45
  identify_consumer_callback: Optional[Callable[[HttpRequest], Union[str, ApitallyConsumer, None]]]
46
+ include_django_views: bool
44
47
  urlconfs: List[Optional[str]]
45
48
  proxy: Optional[str]
46
49
 
@@ -53,6 +56,7 @@ class ApitallyMiddleware:
53
56
  self.drf_available = _check_import("rest_framework")
54
57
  self.drf_endpoint_enumerator = None
55
58
  self.ninja_available = _check_import("ninja")
59
+ self.include_django_views = False
56
60
  self.callbacks = set()
57
61
 
58
62
  if self.config is None:
@@ -68,6 +72,9 @@ class ApitallyMiddleware:
68
72
  self.callbacks.update(_get_drf_callbacks(self.config.urlconfs))
69
73
  if self.ninja_available and None not in self.config.urlconfs:
70
74
  self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
75
+ if self.config.include_django_views:
76
+ self.callbacks.update(_get_django_callbacks(self.config.urlconfs))
77
+ self.include_django_views = True
71
78
 
72
79
  self.client = ApitallyClient(
73
80
  client_id=self.config.client_id,
@@ -98,6 +105,7 @@ class ApitallyMiddleware:
98
105
  request_logging_config: Optional[RequestLoggingConfig] = None,
99
106
  app_version: Optional[str] = None,
100
107
  identify_consumer_callback: Optional[str] = None,
108
+ include_django_views: bool = False,
101
109
  urlconf: Optional[Union[List[Optional[str]], str]] = None,
102
110
  proxy: Optional[str] = None,
103
111
  ) -> None:
@@ -109,6 +117,7 @@ class ApitallyMiddleware:
109
117
  identify_consumer_callback=import_string(identify_consumer_callback)
110
118
  if identify_consumer_callback
111
119
  else None,
120
+ include_django_views=include_django_views,
112
121
  urlconfs=[urlconf] if urlconf is None or isinstance(urlconf, str) else urlconf,
113
122
  proxy=proxy,
114
123
  )
@@ -116,6 +125,7 @@ class ApitallyMiddleware:
116
125
  def __call__(self, request: HttpRequest) -> HttpResponse:
117
126
  if self.client.enabled and request.method is not None and request.method != "OPTIONS":
118
127
  timestamp = time.time()
128
+
119
129
  request_size = parse_int(request.headers.get("Content-Length"))
120
130
  request_body = b""
121
131
  if self.capture_request_body:
@@ -128,32 +138,35 @@ class ApitallyMiddleware:
128
138
  start_time = time.perf_counter()
129
139
  response = self.get_response(request)
130
140
  response_time = time.perf_counter() - start_time
131
- response_size = (
132
- parse_int(response["Content-Length"])
133
- if response.has_header("Content-Length")
134
- else (len(response.content) if not response.streaming else None)
135
- )
136
- response_body = b""
137
- response_content_type = response.get("Content-Type")
138
- if (
139
- self.capture_response_body
140
- and not response.streaming
141
- and RequestLogger.is_supported_content_type(response_content_type)
142
- ):
143
- response_body = (
144
- response.content if response_size is not None and response_size <= MAX_BODY_SIZE else BODY_TOO_LARGE
145
- )
146
-
147
- try:
148
- consumer = self.get_consumer(request)
149
- consumer_identifier = consumer.identifier if consumer else None
150
- self.client.consumer_registry.add_or_update_consumer(consumer)
151
- except Exception: # pragma: no cover
152
- logger.exception("Failed to get consumer for request")
153
- consumer_identifier = None
154
-
155
141
  path = self.get_path(request)
142
+
156
143
  if path is not None:
144
+ try:
145
+ consumer = self.get_consumer(request)
146
+ consumer_identifier = consumer.identifier if consumer else None
147
+ self.client.consumer_registry.add_or_update_consumer(consumer)
148
+ except Exception: # pragma: no cover
149
+ logger.exception("Failed to get consumer for request")
150
+ consumer_identifier = None
151
+
152
+ response_size = (
153
+ parse_int(response["Content-Length"])
154
+ if response.has_header("Content-Length")
155
+ else (len(response.content) if not response.streaming else None)
156
+ )
157
+ response_body = b""
158
+ response_content_type = response.get("Content-Type")
159
+ if (
160
+ self.capture_response_body
161
+ and not response.streaming
162
+ and RequestLogger.is_supported_content_type(response_content_type)
163
+ ):
164
+ response_body = (
165
+ response.content
166
+ if response_size is not None and response_size <= MAX_BODY_SIZE
167
+ else BODY_TOO_LARGE
168
+ )
169
+
157
170
  try:
158
171
  self.client.request_counter.add_request(
159
172
  consumer=consumer_identifier,
@@ -196,27 +209,27 @@ class ApitallyMiddleware:
196
209
  except Exception: # pragma: no cover
197
210
  logger.exception("Failed to log server error")
198
211
 
199
- if self.client.request_logger.enabled:
200
- self.client.request_logger.log_request(
201
- request={
202
- "timestamp": timestamp,
203
- "method": request.method,
204
- "path": path,
205
- "url": request.build_absolute_uri(),
206
- "headers": list(request.headers.items()),
207
- "size": request_size,
208
- "consumer": consumer_identifier,
209
- "body": request_body,
210
- },
211
- response={
212
- "status_code": response.status_code,
213
- "response_time": response_time,
214
- "headers": list(response.items()),
215
- "size": response_size,
216
- "body": response_body,
217
- },
218
- exception=getattr(request, "unhandled_exception", None),
219
- )
212
+ if self.client.request_logger.enabled:
213
+ self.client.request_logger.log_request(
214
+ request={
215
+ "timestamp": timestamp,
216
+ "method": request.method,
217
+ "path": path,
218
+ "url": request.build_absolute_uri(),
219
+ "headers": list(request.headers.items()),
220
+ "size": request_size,
221
+ "consumer": consumer_identifier,
222
+ "body": request_body,
223
+ },
224
+ response={
225
+ "status_code": response.status_code,
226
+ "response_time": response_time,
227
+ "headers": list(response.items()),
228
+ "size": response_size,
229
+ "body": response_body,
230
+ },
231
+ exception=getattr(request, "unhandled_exception", None),
232
+ )
220
233
  else:
221
234
  response = self.get_response(request)
222
235
 
@@ -240,8 +253,9 @@ class ApitallyMiddleware:
240
253
  from ninja.operation import PathView
241
254
 
242
255
  if hasattr(match.func, "__self__") and isinstance(match.func.__self__, PathView):
243
- path = "/" + match.route.lstrip("/")
244
- return re.sub(r"<(?:[^:]+:)?([^>:]+)>", r"{\1}", path)
256
+ return _transform_path(match.route)
257
+ if self.include_django_views:
258
+ return _transform_path(match.route)
245
259
  except Exception: # pragma: no cover
246
260
  logger.exception("Failed to get path for request")
247
261
  return None
@@ -263,10 +277,12 @@ class ApitallyMiddleware:
263
277
  return None
264
278
 
265
279
 
266
- def _get_startup_data(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]:
280
+ def _get_startup_data(
281
+ app_version: Optional[str], urlconfs: List[Optional[str]], include_django_views: bool = False
282
+ ) -> Dict[str, Any]:
267
283
  data: Dict[str, Any] = {}
268
284
  try:
269
- data["paths"] = _get_paths(urlconfs)
285
+ data["paths"] = _get_paths(urlconfs, include_django_views=include_django_views)
270
286
  except Exception: # pragma: no cover
271
287
  data["paths"] = []
272
288
  logger.exception("Failed to get paths")
@@ -293,13 +309,26 @@ def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]:
293
309
  return None # pragma: no cover
294
310
 
295
311
 
296
- def _get_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
312
+ def _get_paths(urlconfs: List[Optional[str]], include_django_views: bool = False) -> List[Dict[str, str]]:
297
313
  paths = []
298
314
  with contextlib.suppress(ImportError):
299
315
  paths.extend(_get_drf_paths(urlconfs))
300
316
  with contextlib.suppress(ImportError):
301
317
  paths.extend(_get_ninja_paths(urlconfs))
302
- return paths
318
+ if include_django_views:
319
+ paths.extend(_get_django_paths(urlconfs))
320
+ return _deduplicate_paths(paths)
321
+
322
+
323
+ def _deduplicate_paths(paths: List[Dict[str, str]]) -> List[Dict[str, str]]:
324
+ seen = set()
325
+ deduplicated_paths = []
326
+ for path in paths:
327
+ key = (path["method"], path["path"])
328
+ if key not in seen:
329
+ seen.add(key)
330
+ deduplicated_paths.append(path)
331
+ return deduplicated_paths
303
332
 
304
333
 
305
334
  def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
@@ -405,6 +434,37 @@ def _get_ninja_api_instances(
405
434
  return apis
406
435
 
407
436
 
437
+ def _get_django_paths(urlconfs: Optional[List[Optional[str]]] = None) -> List[Dict[str, str]]:
438
+ if urlconfs is None:
439
+ urlconfs = [None]
440
+ return [
441
+ {
442
+ "method": method.upper(),
443
+ "path": _transform_path(regex),
444
+ }
445
+ for urlconf in urlconfs
446
+ for callback, regex, _, _ in extract_views_from_urlpatterns(get_resolver(urlconf).url_patterns)
447
+ if hasattr(callback, "view_class") and issubclass(callback.view_class, View)
448
+ for method in callback.view_class.http_method_names
449
+ if method != "options" and hasattr(callback.view_class, method)
450
+ ]
451
+
452
+
453
+ def _get_django_callbacks(urlconfs: Optional[List[Optional[str]]] = None) -> Set[Callable]:
454
+ if urlconfs is None:
455
+ urlconfs = [None]
456
+ return {
457
+ callback
458
+ for urlconf in urlconfs
459
+ for callback, _, _, _ in extract_views_from_urlpatterns(get_resolver(urlconf).url_patterns)
460
+ }
461
+
462
+
463
+ def _transform_path(path: str) -> str:
464
+ path = simplify_regex(path)
465
+ return re.sub(r"<(?:(?P<converter>[^>:]+):)?(?P<parameter>\w+)>", r"{\g<parameter>}", path)
466
+
467
+
408
468
  def _check_import(name: str) -> bool:
409
469
  try:
410
470
  import_module(name)
apitally/starlette.py CHANGED
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
- 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
5
6
  from warnings import warn
6
7
 
7
8
  from httpx import HTTPStatusError, Proxy
9
+ from starlette.applications import Starlette
8
10
  from starlette.datastructures import Headers
9
11
  from starlette.requests import Request
10
12
  from starlette.routing import BaseRoute, Match, Router
11
13
  from starlette.schemas import EndpointInfo, SchemaGenerator
12
14
  from starlette.testclient import TestClient
13
- from starlette.types import ASGIApp, Message, Receive, Scope, Send
15
+ from starlette.types import ASGIApp, Lifespan, Message, Receive, Scope, Send
14
16
 
15
17
  from apitally.client.client_asyncio import ApitallyClient
16
18
  from apitally.client.consumers import Consumer as ApitallyConsumer
@@ -56,10 +58,13 @@ class ApitallyMiddleware:
56
58
  self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
57
59
  )
58
60
 
59
- _register_event_handler(app, "startup", self.on_startup)
60
- _register_event_handler(app, "shutdown", self.client.handle_shutdown)
61
+ _inject_lifespan_handlers(
62
+ app,
63
+ on_startup=self.on_startup,
64
+ on_shutdown=self.client.handle_shutdown,
65
+ )
61
66
 
62
- def on_startup(self) -> None:
67
+ async def on_startup(self) -> None:
63
68
  data = _get_startup_data(self.app, app_version=self.app_version, openapi_url=self.openapi_url)
64
69
  self.client.set_startup_data(data)
65
70
  self.client.start_sync_loop()
@@ -289,8 +294,30 @@ def _get_routes(app: Union[ASGIApp, Router]) -> List[BaseRoute]:
289
294
  return [] # pragma: no cover
290
295
 
291
296
 
292
- def _register_event_handler(app: Union[ASGIApp, Router], event: str, handler: Callable[[], Any]) -> None:
293
- if isinstance(app, Router):
294
- app.add_event_handler(event, handler)
295
- elif hasattr(app, "app"):
296
- _register_event_handler(app.app, event, 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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.16.2
3
+ Version: 0.17.0
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">
@@ -1,14 +1,14 @@
1
1
  apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
2
  apitally/blacksheep.py,sha256=KvcPFeiwQgWZmRglbm8SLaN6_WRs5kZ3SymB1IuLR-A,9616
3
3
  apitally/common.py,sha256=azDxepViH0QW0MuufTHxeSQyLGzCkocAX_KPziWTx8A,1605
4
- apitally/django.py,sha256=f_k7yYlvvvhJMR53NcXCfmlLxLX3CeLO9ephF4bzKbo,16892
4
+ apitally/django.py,sha256=MnyL6ntMYj1WndjLqpDZ-8BrbDWCeVV2dpZbe8Fnm-Y,19254
5
5
  apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
6
6
  apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
7
7
  apitally/fastapi.py,sha256=IfKfgsmIY8_AtnuMTW2sW4qnkya61CAE2vBoIpcc9tk,169
8
8
  apitally/flask.py,sha256=OoCEnjtnD51GUGq-adK80ebuiLj-5HXubxffCv5XTCM,9622
9
9
  apitally/litestar.py,sha256=mHoMqBO_gyoopeHljY8e8GTcV29UDf3uhQMxY3GeNpA,13451
10
10
  apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- apitally/starlette.py,sha256=g6YmC186uG6Lc9-Gx3Pp4ke-jn8IeKK0nFLBGDnWYv0,12771
11
+ apitally/starlette.py,sha256=081vXTNOy6-zZ8ugXknRtQZnFFqG7XKLW4Ho6oaHEUY,13525
12
12
  apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  apitally/client/client_asyncio.py,sha256=rTsH5wlLHK3RmyIuEiT6vzjquU-l2OPC34JnC2U6uYw,6658
14
14
  apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbgw,3799
@@ -20,7 +20,7 @@ apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3
20
20
  apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
21
21
  apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
22
22
  apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
23
- apitally-0.16.2.dist-info/METADATA,sha256=Lycf-s6pDP5lO9B8-pRqcDfHZcJTFZQ--wSuquoDZn0,9321
24
- apitally-0.16.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
- apitally-0.16.2.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
26
- apitally-0.16.2.dist-info/RECORD,,
23
+ apitally-0.17.0.dist-info/METADATA,sha256=ERuU63GwG_N8quZ137RNkkTjfF0r3hAzoM514YjKfvs,9321
24
+ apitally-0.17.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ apitally-0.17.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
26
+ apitally-0.17.0.dist-info/RECORD,,