apitally 0.15.1__py3-none-any.whl → 0.16.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/blacksheep.py ADDED
@@ -0,0 +1,236 @@
1
+ import asyncio
2
+ import time
3
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union
4
+
5
+ from blacksheep import Application, Headers, Request, Response
6
+ from blacksheep.server.openapi.v3 import Info, OpenAPIHandler, Operation
7
+ from blacksheep.server.routing import RouteMatch
8
+
9
+ from apitally.client.client_asyncio import ApitallyClient
10
+ from apitally.client.consumers import Consumer as ApitallyConsumer
11
+ from apitally.client.request_logging import (
12
+ BODY_TOO_LARGE,
13
+ MAX_BODY_SIZE,
14
+ RequestLogger,
15
+ RequestLoggingConfig,
16
+ )
17
+ from apitally.common import get_versions, parse_int
18
+
19
+
20
+ __all__ = ["use_apitally", "ApitallyMiddleware", "ApitallyConsumer", "RequestLoggingConfig"]
21
+
22
+
23
+ def use_apitally(
24
+ app: Application,
25
+ client_id: str,
26
+ env: str = "dev",
27
+ request_logging_config: Optional[RequestLoggingConfig] = None,
28
+ app_version: Optional[str] = None,
29
+ identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
30
+ ) -> None:
31
+ original_get_match = app.router.get_match
32
+
33
+ def _wrapped_router_get_match(request: Request) -> Optional[RouteMatch]:
34
+ match = original_get_match(request)
35
+ if match is not None:
36
+ setattr(request, "_route_pattern", match.pattern.decode())
37
+ return match
38
+
39
+ app.router.get_match = _wrapped_router_get_match # type: ignore[assignment,method-assign]
40
+
41
+ middleware = ApitallyMiddleware(
42
+ app,
43
+ client_id,
44
+ env=env,
45
+ request_logging_config=request_logging_config,
46
+ app_version=app_version,
47
+ identify_consumer_callback=identify_consumer_callback,
48
+ )
49
+ app.middlewares.append(middleware)
50
+
51
+
52
+ class ApitallyMiddleware:
53
+ def __init__(
54
+ self,
55
+ app: Application,
56
+ client_id: str,
57
+ env: str = "dev",
58
+ request_logging_config: Optional[RequestLoggingConfig] = None,
59
+ app_version: Optional[str] = None,
60
+ identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
61
+ ) -> None:
62
+ self.app = app
63
+ self.identify_consumer_callback = identify_consumer_callback
64
+ self.client = ApitallyClient(
65
+ client_id=client_id,
66
+ env=env,
67
+ request_logging_config=request_logging_config,
68
+ )
69
+ self.client.start_sync_loop()
70
+ self._delayed_set_startup_data_task: Optional[asyncio.Task] = None
71
+ self.delayed_set_startup_data(app_version)
72
+ self.app.on_stop += self.on_stop
73
+
74
+ self.capture_request_body = (
75
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_request_body
76
+ )
77
+ self.capture_response_body = (
78
+ self.client.request_logger.config.enabled and self.client.request_logger.config.log_response_body
79
+ )
80
+
81
+ def delayed_set_startup_data(self, app_version: Optional[str] = None) -> None:
82
+ self._delayed_set_startup_data_task = asyncio.create_task(self._delayed_set_startup_data(app_version))
83
+
84
+ async def _delayed_set_startup_data(self, app_version: Optional[str] = None) -> None:
85
+ await asyncio.sleep(1.0) # Short delay to allow app routes to be registered first
86
+ data = _get_startup_data(self.app, app_version=app_version)
87
+ self.client.set_startup_data(data)
88
+
89
+ async def on_stop(self, application: Application) -> None:
90
+ await self.client.handle_shutdown()
91
+
92
+ def get_consumer(self, request: Request) -> Optional[ApitallyConsumer]:
93
+ identity = request.user or request.identity or None
94
+ if identity is not None and identity.has_claim("apitally_consumer"):
95
+ return ApitallyConsumer.from_string_or_object(identity.get("apitally_consumer"))
96
+ if self.identify_consumer_callback is not None:
97
+ consumer = self.identify_consumer_callback(request)
98
+ return ApitallyConsumer.from_string_or_object(consumer)
99
+ return None
100
+
101
+ async def __call__(self, request: Request, handler: Callable[[Request], Awaitable[Response]]) -> Response:
102
+ if not self.client.enabled:
103
+ return await handler(request)
104
+
105
+ timestamp = time.time()
106
+ start_time = time.perf_counter()
107
+ response: Optional[Response] = None
108
+ exception: Optional[BaseException] = None
109
+
110
+ try:
111
+ response = await handler(request)
112
+ except BaseException as e:
113
+ exception = e
114
+ raise e from None
115
+ finally:
116
+ response_time = time.perf_counter() - start_time
117
+
118
+ consumer = self.get_consumer(request)
119
+ consumer_identifier = consumer.identifier if consumer else None
120
+ self.client.consumer_registry.add_or_update_consumer(consumer)
121
+
122
+ route_pattern: Optional[str] = getattr(request, "_route_pattern", None)
123
+ request_size = parse_int(request.get_first_header(b"Content-Length"))
124
+ request_content_type = (request.content_type() or b"").decode() or None
125
+ request_body = b""
126
+
127
+ response_status = response.status if response else 500
128
+ response_size: Optional[int] = None
129
+ response_headers = Headers()
130
+ response_body = b""
131
+
132
+ if self.capture_request_body and RequestLogger.is_supported_content_type(request_content_type):
133
+ if request_size is not None and request_size > MAX_BODY_SIZE:
134
+ request_body = BODY_TOO_LARGE
135
+ else:
136
+ request_body = await request.read() or b""
137
+ if request_size is None:
138
+ request_size = len(request_body)
139
+
140
+ if response is not None:
141
+ response_size = (
142
+ response.content.length
143
+ if response.content
144
+ else parse_int(response.get_first_header(b"Content-Length"))
145
+ )
146
+ response_content_type = (response.content_type() or b"").decode()
147
+
148
+ response_headers = response.headers.clone()
149
+ if not response_headers.contains(b"Content-Type") and response.content:
150
+ response_headers.set(b"Content-Type", response.content.type)
151
+ if not response_headers.contains(b"Content-Length") and response.content:
152
+ response_headers.set(b"Content-Length", str(response.content.length).encode())
153
+
154
+ if self.capture_response_body and RequestLogger.is_supported_content_type(response_content_type):
155
+ if response_size is not None and response_size > MAX_BODY_SIZE:
156
+ response_body = BODY_TOO_LARGE
157
+ else:
158
+ response_body = await response.read() or b""
159
+ if response_size is None or response_size < 0:
160
+ response_size = len(response_body)
161
+
162
+ if route_pattern and request.method.upper() != "OPTIONS":
163
+ self.client.request_counter.add_request(
164
+ consumer=consumer_identifier,
165
+ method=request.method.upper(),
166
+ path=route_pattern,
167
+ status_code=response_status,
168
+ response_time=response_time,
169
+ request_size=request_size,
170
+ response_size=response_size,
171
+ )
172
+
173
+ if response_status == 500 and exception is not None:
174
+ self.client.server_error_counter.add_server_error(
175
+ consumer=consumer_identifier,
176
+ method=request.method.upper(),
177
+ path=route_pattern,
178
+ exception=exception,
179
+ )
180
+
181
+ if self.client.request_logger.enabled:
182
+ self.client.request_logger.log_request(
183
+ request={
184
+ "timestamp": timestamp,
185
+ "method": request.method.upper(),
186
+ "path": route_pattern,
187
+ "url": _get_full_url(request),
188
+ "headers": _transform_headers(request.headers),
189
+ "size": request_size,
190
+ "consumer": consumer_identifier,
191
+ "body": request_body,
192
+ },
193
+ response={
194
+ "status_code": response_status,
195
+ "response_time": response_time,
196
+ "headers": _transform_headers(response_headers),
197
+ "size": response_size,
198
+ "body": response_body,
199
+ },
200
+ exception=exception,
201
+ )
202
+
203
+ return response
204
+
205
+
206
+ def _get_full_url(request: Request) -> str:
207
+ return f"{request.scheme}://{request.host}/{str(request.url).lstrip('/')}"
208
+
209
+
210
+ def _transform_headers(headers: Headers) -> List[Tuple[str, str]]:
211
+ return [(key.decode(), value.decode()) for key, value in headers.items()]
212
+
213
+
214
+ def _get_startup_data(app: Application, app_version: Optional[str] = None) -> Dict[str, Any]:
215
+ return {
216
+ "paths": _get_paths(app),
217
+ "versions": get_versions("blacksheep", app_version=app_version),
218
+ "client": "python:blacksheep",
219
+ }
220
+
221
+
222
+ def _get_paths(app: Application) -> List[Dict[str, str]]:
223
+ openapi = OpenAPIHandler(info=Info(title="", version=""))
224
+ paths = []
225
+ methods = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
226
+ for path, path_item in openapi.get_paths(app).items():
227
+ for method in methods:
228
+ operation: Operation = getattr(path_item, method, None)
229
+ if operation is not None:
230
+ item = {"method": method.upper(), "path": path}
231
+ if operation.summary:
232
+ item["summary"] = operation.summary
233
+ if operation.description:
234
+ item["description"] = operation.description
235
+ paths.append(item)
236
+ return paths
@@ -225,6 +225,11 @@ class RequestLogger:
225
225
  request["headers"] = self._mask_headers(request["headers"]) if self.config.log_request_headers else []
226
226
  response["headers"] = self._mask_headers(response["headers"]) if self.config.log_response_headers else []
227
227
 
228
+ if request["size"] is not None and request["size"] < 0:
229
+ request["size"] = None
230
+ if response["size"] is not None and response["size"] < 0:
231
+ response["size"] = None
232
+
228
233
  item: Dict[str, Any] = {
229
234
  "uuid": str(uuid4()),
230
235
  "request": _skip_empty_values(request),
@@ -49,15 +49,17 @@ class RequestCounter:
49
49
  if request_size is not None:
50
50
  with contextlib.suppress(ValueError):
51
51
  request_size = int(request_size)
52
- request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
53
- self.request_size_sums[request_info] += request_size
54
- self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
52
+ if request_size >= 0:
53
+ request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
54
+ self.request_size_sums[request_info] += request_size
55
+ self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
55
56
  if response_size is not None:
56
57
  with contextlib.suppress(ValueError):
57
58
  response_size = int(response_size)
58
- response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
59
- self.response_size_sums[request_info] += response_size
60
- self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
59
+ if response_size >= 0:
60
+ response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
61
+ self.response_size_sums[request_info] += response_size
62
+ self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
61
63
 
62
64
  def get_and_reset_requests(self) -> List[Dict[str, Any]]:
63
65
  data: List[Dict[str, Any]] = []
apitally/common.py CHANGED
@@ -5,7 +5,7 @@ from importlib.metadata import PackageNotFoundError, version
5
5
  from typing import Any, Dict, Optional, Union
6
6
 
7
7
 
8
- def parse_int(x: Union[str, int, None]) -> Optional[int]:
8
+ def parse_int(x: Union[str, bytes, int, None]) -> Optional[int]:
9
9
  if x is None:
10
10
  return None
11
11
  try:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: apitally
3
- Version: 0.15.1
4
- Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
3
+ Version: 0.16.0
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
7
7
  Project-URL: Repository, https://github.com/apitally/apitally-py
@@ -24,6 +24,7 @@ Classifier: Programming Language :: Python :: 3.9
24
24
  Classifier: Programming Language :: Python :: 3.10
25
25
  Classifier: Programming Language :: Python :: 3.11
26
26
  Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
27
28
  Classifier: Topic :: Internet
28
29
  Classifier: Topic :: Internet :: WWW/HTTP
29
30
  Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
@@ -32,6 +33,9 @@ Classifier: Topic :: System :: Monitoring
32
33
  Classifier: Typing :: Typed
33
34
  Requires-Python: <4.0,>=3.8
34
35
  Requires-Dist: backoff>=2.0.0
36
+ Provides-Extra: blacksheep
37
+ Requires-Dist: blacksheep>=2; extra == 'blacksheep'
38
+ Requires-Dist: httpx>=0.22.0; extra == 'blacksheep'
35
39
  Provides-Extra: django-ninja
36
40
  Requires-Dist: django-ninja>=0.18.0; extra == 'django-ninja'
37
41
  Requires-Dist: django<5,>=2.2; (python_version < '3.10') and extra == 'django-ninja'
@@ -94,6 +98,7 @@ This SDK for Apitally currently supports the following Python web frameworks:
94
98
  - [Flask](https://docs.apitally.io/frameworks/flask)
95
99
  - [Starlette](https://docs.apitally.io/frameworks/starlette)
96
100
  - [Litestar](https://docs.apitally.io/frameworks/litestar)
101
+ - [BlackSheep](https://docs.apitally.io/frameworks/blacksheep)
97
102
 
98
103
  Learn more about Apitally on our 🌎 [website](https://apitally.io) or check out
99
104
  the 📚 [documentation](https://docs.apitally.io).
@@ -235,6 +240,24 @@ app = Litestar(
235
240
  )
236
241
  ```
237
242
 
243
+ ### BlackSheep
244
+
245
+ This is an example of how to add the Apitally middleware to a BlackSheep
246
+ application. For further instructions, see our
247
+ [setup guide for BlackSheep](https://docs.apitally.io/frameworks/blacksheep).
248
+
249
+ ```python
250
+ from blacksheep import Application
251
+ from apitally.blacksheep import use_apitally
252
+
253
+ app = Application()
254
+ use_apitally(
255
+ app,
256
+ client_id="your-client-id",
257
+ env="dev", # or "prod" etc.
258
+ )
259
+ ```
260
+
238
261
  ## Getting help
239
262
 
240
263
  If you need help please
@@ -1,5 +1,6 @@
1
1
  apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
2
- apitally/common.py,sha256=FMDBPlYHCqomgAq-Z8JiyTSMAoqJRycPsJzsxncQqQA,1598
2
+ apitally/blacksheep.py,sha256=LSdg5LG-bnXdhKtPkp71CBQKGVtUi3t_EirovoaRy-s,9973
3
+ apitally/common.py,sha256=azDxepViH0QW0MuufTHxeSQyLGzCkocAX_KPziWTx8A,1605
3
4
  apitally/django.py,sha256=zwe8svC8rfo7TyHfOlkYTeXptxPFoRjvt0bbYvgtJKM,16892
4
5
  apitally/django_ninja.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
5
6
  apitally/django_rest_framework.py,sha256=-CmrwFFRv7thFOUK_OrOSouhHL9bm5sIBNIQlpyE_2c,166
@@ -14,12 +15,12 @@ apitally/client/client_base.py,sha256=DvivGeHd3dyOASRvkIo44Zh8RzdBMfH8_rROa2lFbg
14
15
  apitally/client/client_threading.py,sha256=7JPu2Uulev7X2RiSLx4HJYfvAP6Z5zB_yuSevMfQC7I,7389
15
16
  apitally/client/consumers.py,sha256=w_AFQhVgdtJVt7pVySBvSZwQg-2JVqmD2JQtVBoMkus,2626
16
17
  apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
17
- apitally/client/request_logging.py,sha256=OL1jlpHXYpZw2VKRekgNSwZQ0qZJeiiJxKeTYrmP22g,13913
18
- apitally/client/requests.py,sha256=RdJyvIqQGVHvS-wjpAPUwcO7byOJ6jO8dYqNTU2Furg,3685
18
+ apitally/client/request_logging.py,sha256=SMvQd3WDolnb8u9rHVh2_OgXwFjL2jLZt-GpZNQ1XGk,14115
19
+ apitally/client/requests.py,sha256=SDptGOg9XvaEKFj2o3oxJz-JAuZzUrqpHnbOQixf99o,3794
19
20
  apitally/client/sentry.py,sha256=qMjHdI0V7c50ruo1WjmjWc8g6oGDv724vSCvcuZ8G9k,1188
20
21
  apitally/client/server_errors.py,sha256=4B2BKDFoIpoWc55UVH6AIdYSgzj6zxCdMNUW77JjhZw,3423
21
22
  apitally/client/validation_errors.py,sha256=6G8WYWFgJs9VH9swvkPXJGuOJgymj5ooWA9OwjUTbuM,1964
22
- apitally-0.15.1.dist-info/METADATA,sha256=Z8Es_x6H-rxC5KB26S0i1BFGVBlISkQfNiYPglyS09E,8643
23
- apitally-0.15.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
24
- apitally-0.15.1.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
25
- apitally-0.15.1.dist-info/RECORD,,
23
+ apitally-0.16.0.dist-info/METADATA,sha256=kWbylw8pJ0Q0xHBt1P7S3h6cTqx9UQ8SBAnrxFew5CM,9321
24
+ apitally-0.16.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ apitally-0.16.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
26
+ apitally-0.16.0.dist-info/RECORD,,