apitally 0.6.3__py3-none-any.whl → 0.7.1__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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.6.3"
1
+ __version__ = "0.7.1"
apitally/common.py ADDED
@@ -0,0 +1,31 @@
1
+ import sys
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ from typing import Dict, Optional
4
+
5
+
6
+ def get_versions(*packages, app_version: Optional[str] = None) -> Dict[str, str]:
7
+ versions = _get_common_package_versions()
8
+ for package in packages:
9
+ versions[package] = _get_package_version(package)
10
+ if app_version:
11
+ versions["app"] = app_version
12
+ return {n: v for n, v in versions.items() if v is not None}
13
+
14
+
15
+ def _get_common_package_versions() -> Dict[str, Optional[str]]:
16
+ return {
17
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
18
+ "apitally": _get_package_version("apitally"),
19
+ "uvicorn": _get_package_version("uvicorn"),
20
+ "hypercorn": _get_package_version("hypercorn"),
21
+ "daphne": _get_package_version("daphne"),
22
+ "gunicorn": _get_package_version("gunicorn"),
23
+ "uwsgi": _get_package_version("uwsgi"),
24
+ }
25
+
26
+
27
+ def _get_package_version(name: str) -> Optional[str]:
28
+ try:
29
+ return version(name)
30
+ except PackageNotFoundError:
31
+ return None
apitally/django.py CHANGED
@@ -1,26 +1,29 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import json
4
- import sys
5
+ import re
5
6
  import time
6
7
  from dataclasses import dataclass
7
- from importlib.metadata import PackageNotFoundError, version
8
- from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
8
+ from importlib import import_module
9
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
9
10
 
10
11
  from django.conf import settings
11
- from django.core.exceptions import ViewDoesNotExist
12
- from django.test import RequestFactory
13
- from django.urls import Resolver404, URLPattern, URLResolver, get_resolver, resolve
12
+ from django.urls import URLPattern, URLResolver, get_resolver
14
13
  from django.utils.module_loading import import_string
15
14
 
15
+ from apitally.client.logging import get_logger
16
16
  from apitally.client.threading import ApitallyClient
17
+ from apitally.common import get_versions
17
18
 
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from django.http import HttpRequest, HttpResponse
22
+ from ninja import NinjaAPI
21
23
 
22
24
 
23
25
  __all__ = ["ApitallyMiddleware"]
26
+ logger = get_logger(__name__)
24
27
 
25
28
 
26
29
  @dataclass
@@ -28,8 +31,8 @@ class ApitallyMiddlewareConfig:
28
31
  client_id: str
29
32
  env: str
30
33
  app_version: Optional[str]
31
- openapi_url: Optional[str]
32
34
  identify_consumer_callback: Optional[Callable[[HttpRequest], Optional[str]]]
35
+ urlconfs: List[Optional[str]]
33
36
 
34
37
 
35
38
  class ApitallyMiddleware:
@@ -37,21 +40,31 @@ class ApitallyMiddleware:
37
40
 
38
41
  def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
39
42
  self.get_response = get_response
43
+ self.drf_available = _check_import("rest_framework")
44
+ self.drf_endpoint_enumerator = None
45
+ self.ninja_available = _check_import("ninja")
46
+ self.callbacks = set()
47
+
40
48
  if self.config is None:
41
49
  config = getattr(settings, "APITALLY_MIDDLEWARE", {})
42
50
  self.configure(**config)
43
51
  assert self.config is not None
44
- views = _extract_views_from_url_patterns(get_resolver().url_patterns)
45
- self.view_lookup = {
46
- view.pattern: view for view in reversed(_extract_views_from_url_patterns(get_resolver().url_patterns))
47
- }
52
+
53
+ if self.drf_available:
54
+ from rest_framework.schemas.generators import EndpointEnumerator
55
+
56
+ self.drf_endpoint_enumerator = EndpointEnumerator()
57
+ if None not in self.config.urlconfs:
58
+ self.callbacks.update(_get_drf_callbacks(self.config.urlconfs))
59
+ if self.ninja_available and None not in self.config.urlconfs:
60
+ self.callbacks.update(_get_ninja_callbacks(self.config.urlconfs))
61
+
48
62
  self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
49
63
  self.client.start_sync_loop()
50
64
  self.client.set_app_info(
51
65
  app_info=_get_app_info(
52
- views=views,
53
66
  app_version=self.config.app_version,
54
- openapi_url=self.config.openapi_url,
67
+ urlconfs=self.config.urlconfs,
55
68
  )
56
69
  )
57
70
 
@@ -61,212 +74,236 @@ class ApitallyMiddleware:
61
74
  client_id: str,
62
75
  env: str = "dev",
63
76
  app_version: Optional[str] = None,
64
- openapi_url: Optional[str] = None,
65
77
  identify_consumer_callback: Optional[str] = None,
78
+ urlconf: Optional[Union[List[Optional[str]], str]] = None,
66
79
  ) -> None:
67
80
  cls.config = ApitallyMiddlewareConfig(
68
81
  client_id=client_id,
69
82
  env=env,
70
83
  app_version=app_version,
71
- openapi_url=openapi_url,
72
84
  identify_consumer_callback=import_string(identify_consumer_callback)
73
85
  if identify_consumer_callback
74
86
  else None,
87
+ urlconfs=[urlconf] if urlconf is None or isinstance(urlconf, str) else urlconf,
75
88
  )
76
89
 
77
90
  def __call__(self, request: HttpRequest) -> HttpResponse:
78
- view = self.get_view(request)
79
91
  start_time = time.perf_counter()
80
92
  response = self.get_response(request)
81
- if request.method is not None and view is not None and view.is_api_view:
93
+ response_time = time.perf_counter() - start_time
94
+ path = self.get_path(request)
95
+ if request.method is not None and path is not None:
82
96
  consumer = self.get_consumer(request)
83
- self.client.request_counter.add_request(
84
- consumer=consumer,
85
- method=request.method,
86
- path=view.pattern,
87
- status_code=response.status_code,
88
- response_time=time.perf_counter() - start_time,
89
- request_size=request.headers.get("Content-Length"),
90
- response_size=response["Content-Length"]
91
- if response.has_header("Content-Length")
92
- else (len(response.content) if not response.streaming else None),
93
- )
97
+ try:
98
+ self.client.request_counter.add_request(
99
+ consumer=consumer,
100
+ method=request.method,
101
+ path=path,
102
+ status_code=response.status_code,
103
+ response_time=response_time,
104
+ request_size=request.headers.get("Content-Length"),
105
+ response_size=response["Content-Length"]
106
+ if response.has_header("Content-Length")
107
+ else (len(response.content) if not response.streaming else None),
108
+ )
109
+ except Exception: # pragma: no cover
110
+ logger.exception("Failed to log request metadata")
94
111
  if (
95
112
  response.status_code == 422
96
113
  and (content_type := response.get("Content-Type")) is not None
97
114
  and content_type.startswith("application/json")
98
115
  ):
99
116
  try:
100
- body = json.loads(response.content)
101
- if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
102
- # Log Django Ninja / Pydantic validation errors
103
- self.client.validation_error_counter.add_validation_errors(
104
- consumer=consumer,
105
- method=request.method,
106
- path=view.pattern,
107
- detail=body["detail"],
108
- )
109
- except json.JSONDecodeError: # pragma: no cover
110
- pass
117
+ with contextlib.suppress(json.JSONDecodeError):
118
+ body = json.loads(response.content)
119
+ if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
120
+ # Log Django Ninja / Pydantic validation errors
121
+ self.client.validation_error_counter.add_validation_errors(
122
+ consumer=consumer,
123
+ method=request.method,
124
+ path=path,
125
+ detail=body["detail"],
126
+ )
127
+ except Exception: # pragma: no cover
128
+ logger.exception("Failed to log validation errors")
111
129
  return response
112
130
 
113
- def get_view(self, request: HttpRequest) -> Optional[DjangoViewInfo]:
114
- try:
115
- resolver_match = resolve(request.path_info)
116
- return self.view_lookup.get(resolver_match.route)
117
- except Resolver404: # pragma: no cover
118
- return None
131
+ def get_path(self, request: HttpRequest) -> Optional[str]:
132
+ if (match := request.resolver_match) is not None:
133
+ try:
134
+ if self.callbacks and match.func not in self.callbacks:
135
+ return None
136
+ if self.drf_endpoint_enumerator is not None:
137
+ from rest_framework.schemas.generators import is_api_view
138
+
139
+ if is_api_view(match.func):
140
+ return self.drf_endpoint_enumerator.get_path_from_regex(match.route)
141
+ if self.ninja_available:
142
+ from ninja.operation import PathView
143
+
144
+ if hasattr(match.func, "__self__") and isinstance(match.func.__self__, PathView):
145
+ path = "/" + match.route.lstrip("/")
146
+ return re.sub(r"<(?:[^:]+:)?([^>:]+)>", r"{\1}", path)
147
+ except Exception: # pragma: no cover
148
+ logger.exception("Failed to get path for request")
149
+ return None
119
150
 
120
151
  def get_consumer(self, request: HttpRequest) -> Optional[str]:
121
- if hasattr(request, "consumer_identifier"):
122
- return str(request.consumer_identifier)
123
- if self.config is not None and self.config.identify_consumer_callback is not None:
124
- consumer_identifier = self.config.identify_consumer_callback(request)
125
- if consumer_identifier is not None:
126
- return str(consumer_identifier)
152
+ try:
153
+ if hasattr(request, "consumer_identifier"):
154
+ return str(request.consumer_identifier)
155
+ if self.config is not None and self.config.identify_consumer_callback is not None:
156
+ consumer_identifier = self.config.identify_consumer_callback(request)
157
+ if consumer_identifier is not None:
158
+ return str(consumer_identifier)
159
+ except Exception: # pragma: no cover
160
+ logger.exception("Failed to get consumer identifier for request")
127
161
  return None
128
162
 
129
163
 
130
- @dataclass
131
- class DjangoViewInfo:
132
- func: Callable
133
- pattern: str
134
- name: Optional[str] = None
135
-
136
- @property
137
- def is_api_view(self) -> bool:
138
- return self.is_rest_framework_api_view or self.is_ninja_path_view
139
-
140
- @property
141
- def is_rest_framework_api_view(self) -> bool:
142
- try:
143
- from rest_framework.views import APIView
144
-
145
- return hasattr(self.func, "view_class") and issubclass(self.func.view_class, APIView)
146
- except ImportError: # pragma: no cover
147
- return False
164
+ def _get_app_info(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]:
165
+ app_info: Dict[str, Any] = {}
166
+ try:
167
+ app_info["paths"] = _get_paths(urlconfs)
168
+ except Exception: # pragma: no cover
169
+ app_info["paths"] = []
170
+ logger.exception("Failed to get paths")
171
+ try:
172
+ app_info["openapi"] = _get_openapi(urlconfs)
173
+ except Exception: # pragma: no cover
174
+ logger.exception("Failed to get OpenAPI schema")
175
+ app_info["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version)
176
+ app_info["client"] = "python:django"
177
+ return app_info
148
178
 
149
- @property
150
- def is_ninja_path_view(self) -> bool:
151
- try:
152
- from ninja.operation import PathView
153
179
 
154
- return hasattr(self.func, "__self__") and isinstance(self.func.__self__, PathView)
155
- except ImportError: # pragma: no cover
156
- return False
180
+ def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]:
181
+ drf_schema = None
182
+ ninja_schema = None
183
+ with contextlib.suppress(ImportError):
184
+ drf_schema = _get_drf_schema(urlconfs)
185
+ with contextlib.suppress(ImportError):
186
+ ninja_schema = _get_ninja_schema(urlconfs)
187
+ if drf_schema is not None and ninja_schema is None:
188
+ return json.dumps(drf_schema)
189
+ elif ninja_schema is not None and drf_schema is None:
190
+ return json.dumps(ninja_schema)
191
+ return None # pragma: no cover
157
192
 
158
- @property
159
- def allowed_methods(self) -> List[str]:
160
- if hasattr(self.func, "view_class"):
161
- return [method.upper() for method in self.func.view_class().allowed_methods]
162
- if self.is_ninja_path_view:
163
- assert hasattr(self.func, "__self__")
164
- return [method.upper() for operation in self.func.__self__.operations for method in operation.methods]
165
- return [] # pragma: no cover
166
193
 
194
+ def _get_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
195
+ paths = []
196
+ with contextlib.suppress(ImportError):
197
+ paths.extend(_get_drf_paths(urlconfs))
198
+ with contextlib.suppress(ImportError):
199
+ paths.extend(_get_ninja_paths(urlconfs))
200
+ return paths
167
201
 
168
- def _get_app_info(
169
- views: List[DjangoViewInfo], app_version: Optional[str] = None, openapi_url: Optional[str] = None
170
- ) -> Dict[str, Any]:
171
- app_info: Dict[str, Any] = {}
172
- if openapi := _get_openapi(views, openapi_url):
173
- app_info["openapi"] = openapi
174
- app_info["paths"] = _get_paths(views)
175
- app_info["versions"] = _get_versions(app_version)
176
- app_info["client"] = "python:django"
177
- return app_info
178
202
 
203
+ def _get_drf_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
204
+ from rest_framework.schemas.generators import EndpointEnumerator
179
205
 
180
- def _get_paths(views: List[DjangoViewInfo]) -> List[Dict[str, str]]:
206
+ enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs]
181
207
  return [
182
- {"method": method, "path": view.pattern}
183
- for view in views
184
- if view.is_api_view
185
- for method in view.allowed_methods
208
+ {
209
+ "method": method.upper(),
210
+ "path": path,
211
+ }
212
+ for enumerator in enumerators
213
+ for path, method, _ in enumerator.get_api_endpoints()
186
214
  if method not in ["HEAD", "OPTIONS"]
187
215
  ]
188
216
 
189
217
 
190
- def _get_openapi(views: List[DjangoViewInfo], openapi_url: Optional[str] = None) -> Optional[str]:
191
- openapi_views = [
192
- view
193
- for view in views
194
- if (openapi_url is not None and view.pattern == openapi_url.removeprefix("/"))
195
- or (openapi_url is None and view.pattern.endswith("openapi.json") and "<" not in view.pattern)
196
- ]
197
- if len(openapi_views) == 1:
198
- rf = RequestFactory()
199
- request = rf.get(openapi_views[0].pattern)
200
- response = openapi_views[0].func(request)
201
- if response.status_code == 200:
202
- return response.content.decode()
203
- return None
204
-
205
-
206
- def _extract_views_from_url_patterns(
207
- url_patterns: List[Any], base: str = "", namespace: Optional[str] = None
208
- ) -> List[DjangoViewInfo]:
209
- # Copied and adapted from django-extensions.
210
- # See https://github.com/django-extensions/django-extensions/blob/dd794f1b239d657f62d40f2c3178200978328ed7/django_extensions/management/commands/show_urls.py#L190C34-L190C34
211
- views = []
212
- for p in url_patterns:
213
- if isinstance(p, URLPattern):
214
- try:
215
- if not p.name:
216
- name = p.name
217
- elif namespace:
218
- name = f"{namespace}:{p.name}"
219
- else:
220
- name = p.name
221
- views.append(DjangoViewInfo(func=p.callback, pattern=base + str(p.pattern), name=name))
222
- except ViewDoesNotExist:
223
- continue
224
- elif isinstance(p, URLResolver):
225
- try:
226
- patterns = p.url_patterns
227
- except ImportError:
228
- continue
229
- views.extend(
230
- _extract_views_from_url_patterns(
231
- patterns,
232
- base + str(p.pattern),
233
- namespace=f"{namespace}:{p.namespace}" if namespace and p.namespace else p.namespace or namespace,
234
- )
235
- )
236
- elif hasattr(p, "_get_callback"):
237
- try:
238
- views.append(DjangoViewInfo(func=p._get_callback(), pattern=base + str(p.pattern), name=p.name))
239
- except ViewDoesNotExist:
240
- continue
241
- elif hasattr(p, "url_patterns"):
242
- try:
243
- patterns = p.url_patterns
244
- except ImportError:
245
- continue
246
- views.extend(
247
- _extract_views_from_url_patterns(
248
- patterns,
249
- base + str(p.pattern),
250
- namespace=namespace,
251
- )
252
- )
253
- return views
218
+ def _get_drf_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
219
+ from rest_framework.schemas.generators import EndpointEnumerator
220
+
221
+ enumerators = [EndpointEnumerator(urlconf=urlconf) for urlconf in urlconfs]
222
+ return {callback for enumerator in enumerators for _, _, callback in enumerator.get_api_endpoints()}
223
+
224
+
225
+ def _get_drf_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
226
+ from rest_framework.schemas.openapi import SchemaGenerator
227
+
228
+ schemas = []
229
+ with contextlib.suppress(AssertionError): # uritemplate and inflection must be installed for OpenAPI schema support
230
+ for urlconf in urlconfs:
231
+ generator = SchemaGenerator(urlconf=urlconf)
232
+ schema = generator.get_schema()
233
+ if schema is not None and len(schema["paths"]) > 0:
234
+ schemas.append(schema)
235
+ return None if len(schemas) != 1 else schemas[0] # type: ignore[return-value]
236
+
237
+
238
+ def _get_ninja_paths(urlconfs: List[Optional[str]]) -> List[Dict[str, str]]:
239
+ endpoints = []
240
+ for api in _get_ninja_api_instances(urlconfs=urlconfs):
241
+ schema = api.get_openapi_schema()
242
+ for path, operations in schema["paths"].items():
243
+ for method, operation in operations.items():
244
+ if method not in ["HEAD", "OPTIONS"]:
245
+ endpoints.append(
246
+ {
247
+ "method": method,
248
+ "path": path,
249
+ "summary": operation.get("summary"),
250
+ "description": operation.get("description"),
251
+ }
252
+ )
253
+ return endpoints
254
+
255
+
256
+ def _get_ninja_callbacks(urlconfs: List[Optional[str]]) -> Set[Callable]:
257
+ return {
258
+ path_view.get_view()
259
+ for api in _get_ninja_api_instances(urlconfs=urlconfs)
260
+ for _, router in api._routers
261
+ for path_view in router.path_operations.values()
262
+ }
254
263
 
255
264
 
256
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
257
- versions = {
258
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
259
- "apitally": version("apitally"),
260
- "django": version("django"),
261
- }
262
- try:
263
- versions["djangorestframework"] = version("djangorestframework")
264
- except PackageNotFoundError: # pragma: no cover
265
- pass
265
+ def _get_ninja_schema(urlconfs: List[Optional[str]]) -> Optional[Dict[str, Any]]:
266
+ schemas = []
267
+ for api in _get_ninja_api_instances(urlconfs=urlconfs):
268
+ schema = api.get_openapi_schema()
269
+ if len(schema["paths"]) > 0:
270
+ schemas.append(schema)
271
+ return None if len(schemas) != 1 else schemas[0]
272
+
273
+
274
+ def _get_ninja_api_instances(
275
+ urlconfs: Optional[List[Optional[str]]] = None,
276
+ patterns: Optional[List[Any]] = None,
277
+ ) -> Set[NinjaAPI]:
278
+ from ninja import NinjaAPI
279
+
280
+ if urlconfs is None:
281
+ urlconfs = [None]
282
+ if patterns is None:
283
+ patterns = []
284
+ for urlconf in urlconfs:
285
+ patterns.extend(get_resolver(urlconf).url_patterns)
286
+
287
+ apis: Set[NinjaAPI] = set()
288
+ for p in patterns:
289
+ if isinstance(p, URLResolver):
290
+ if p.app_name != "ninja":
291
+ apis.update(_get_ninja_api_instances(patterns=p.url_patterns))
292
+ else:
293
+ for pattern in p.url_patterns:
294
+ if isinstance(pattern, URLPattern) and pattern.lookup_str.startswith("ninja."):
295
+ callback_keywords = getattr(pattern.callback, "keywords", {})
296
+ if isinstance(callback_keywords, dict):
297
+ api = callback_keywords.get("api")
298
+ if isinstance(api, NinjaAPI):
299
+ apis.add(api)
300
+ break
301
+ return apis
302
+
303
+
304
+ def _check_import(name: str) -> bool:
266
305
  try:
267
- versions["django-ninja"] = version("django-ninja")
268
- except PackageNotFoundError: # pragma: no cover
269
- pass
270
- if app_version:
271
- versions["app"] = app_version
272
- return versions
306
+ import_module(name)
307
+ return True
308
+ except ImportError:
309
+ return False
apitally/flask.py CHANGED
@@ -1,8 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
4
3
  import time
5
- from importlib.metadata import version
6
4
  from threading import Timer
7
5
  from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
8
6
 
@@ -12,6 +10,7 @@ from werkzeug.exceptions import NotFound
12
10
  from werkzeug.test import Client
13
11
 
14
12
  from apitally.client.threading import ApitallyClient
13
+ from apitally.common import get_versions
15
14
 
16
15
 
17
16
  if TYPE_CHECKING:
@@ -103,7 +102,7 @@ def _get_app_info(app: Flask, app_version: Optional[str] = None, openapi_url: Op
103
102
  app_info["openapi"] = openapi
104
103
  if paths := _get_paths(app.url_map):
105
104
  app_info["paths"] = paths
106
- app_info["versions"] = _get_versions(app_version)
105
+ app_info["versions"] = get_versions("flask", app_version=app_version)
107
106
  app_info["client"] = "python:flask"
108
107
  return app_info
109
108
 
@@ -124,14 +123,3 @@ def _get_openapi(app: WSGIApplication, openapi_url: str) -> Optional[str]:
124
123
  if response.status_code != 200:
125
124
  return None
126
125
  return response.get_data(as_text=True)
127
-
128
-
129
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
130
- versions = {
131
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
132
- "apitally": version("apitally"),
133
- "flask": version("flask"),
134
- }
135
- if app_version:
136
- versions["app"] = app_version
137
- return versions
apitally/litestar.py CHANGED
@@ -1,8 +1,6 @@
1
1
  import contextlib
2
2
  import json
3
- import sys
4
3
  import time
5
- from importlib.metadata import version
6
4
  from typing import Callable, Dict, List, Optional
7
5
 
8
6
  from litestar.app import DEFAULT_OPENAPI_CONFIG, Litestar
@@ -15,6 +13,7 @@ from litestar.plugins import InitPluginProtocol
15
13
  from litestar.types import ASGIApp, Message, Receive, Scope, Send
16
14
 
17
15
  from apitally.client.asyncio import ApitallyClient
16
+ from apitally.common import get_versions
18
17
 
19
18
 
20
19
  __all__ = ["ApitallyPlugin"]
@@ -48,7 +47,7 @@ class ApitallyPlugin(InitPluginProtocol):
48
47
  app_info = {
49
48
  "openapi": _get_openapi(app),
50
49
  "paths": [route for route in _get_routes(app) if not self.filter_path(route["path"])],
51
- "versions": _get_versions(self.app_version),
50
+ "versions": get_versions("litestar", app_version=self.app_version),
52
51
  "client": "python:litestar",
53
52
  }
54
53
  self.client.set_app_info(app_info)
@@ -174,14 +173,3 @@ def _get_routes(app: Litestar) -> List[Dict[str, str]]:
174
173
  for method in route.methods
175
174
  if route.scope_type == ScopeType.HTTP and method != "OPTIONS"
176
175
  ]
177
-
178
-
179
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
180
- versions = {
181
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
182
- "apitally": version("apitally"),
183
- "litestar": version("litestar"),
184
- }
185
- if app_version:
186
- versions["app"] = app_version
187
- return versions
apitally/starlette.py CHANGED
@@ -2,9 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
- import sys
6
5
  import time
7
- from importlib.metadata import PackageNotFoundError, version
8
6
  from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
9
7
 
10
8
  from httpx import HTTPStatusError
@@ -17,6 +15,7 @@ from starlette.testclient import TestClient
17
15
  from starlette.types import ASGIApp
18
16
 
19
17
  from apitally.client.asyncio import ApitallyClient
18
+ from apitally.common import get_versions
20
19
 
21
20
 
22
21
  if TYPE_CHECKING:
@@ -145,7 +144,7 @@ def _get_app_info(app: ASGIApp, app_version: Optional[str] = None, openapi_url:
145
144
  app_info["openapi"] = openapi
146
145
  if endpoints := _get_endpoint_info(app):
147
146
  app_info["paths"] = [{"path": endpoint.path, "method": endpoint.http_method} for endpoint in endpoints]
148
- app_info["versions"] = _get_versions(app_version)
147
+ app_info["versions"] = get_versions("fastapi", "starlette", app_version=app_version)
149
148
  app_info["client"] = "python:starlette"
150
149
  return app_info
151
150
 
@@ -179,18 +178,3 @@ def _register_shutdown_handler(app: Union[ASGIApp, Router], shutdown_handler: Ca
179
178
  app.add_event_handler("shutdown", shutdown_handler)
180
179
  elif hasattr(app, "app"):
181
180
  _register_shutdown_handler(app.app, shutdown_handler)
182
-
183
-
184
- def _get_versions(app_version: Optional[str]) -> Dict[str, str]:
185
- versions = {
186
- "python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
187
- "apitally": version("apitally"),
188
- "starlette": version("starlette"),
189
- }
190
- try:
191
- versions["fastapi"] = version("fastapi")
192
- except PackageNotFoundError: # pragma: no cover
193
- pass
194
- if app_version:
195
- versions["app"] = app_version
196
- return versions
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.6.3
4
- Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, and Starlette.
3
+ Version: 0.7.1
4
+ Summary: API monitoring for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Home-page: https://apitally.io
6
6
  License: MIT
7
7
  Author: Apitally
@@ -21,7 +21,6 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Topic :: Internet :: WWW/HTTP
22
22
  Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware
23
23
  Classifier: Typing :: Typed
24
- Provides-Extra: django
25
24
  Provides-Extra: django-ninja
26
25
  Provides-Extra: django-rest-framework
27
26
  Provides-Extra: fastapi
@@ -29,15 +28,17 @@ Provides-Extra: flask
29
28
  Provides-Extra: litestar
30
29
  Provides-Extra: starlette
31
30
  Requires-Dist: backoff (>=2.0.0)
32
- Requires-Dist: django (>=2.2) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework"
31
+ Requires-Dist: django (>=2.2) ; extra == "django-ninja" or extra == "django-rest-framework"
33
32
  Requires-Dist: django-ninja (>=0.18.0) ; extra == "django-ninja"
34
33
  Requires-Dist: djangorestframework (>=3.10.0) ; extra == "django-rest-framework"
35
34
  Requires-Dist: fastapi (>=0.87.0) ; extra == "fastapi"
36
35
  Requires-Dist: flask (>=2.0.0) ; extra == "flask"
37
36
  Requires-Dist: httpx (>=0.22.0) ; extra == "fastapi" or extra == "litestar" or extra == "starlette"
37
+ Requires-Dist: inflection (>=0.5.1) ; extra == "django-rest-framework"
38
38
  Requires-Dist: litestar (>=2.0.0) ; extra == "litestar"
39
- Requires-Dist: requests (>=2.26.0) ; extra == "django" or extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
39
+ Requires-Dist: requests (>=2.26.0) ; extra == "django-ninja" or extra == "django-rest-framework" or extra == "flask"
40
40
  Requires-Dist: starlette (>=0.21.0,<1.0.0) ; extra == "fastapi" or extra == "starlette"
41
+ Requires-Dist: uritemplate (>=3.0.0) ; extra == "django-rest-framework"
41
42
  Project-URL: Documentation, https://docs.apitally.io
42
43
  Project-URL: Repository, https://github.com/apitally/python-client
43
44
  Description-Content-Type: text/markdown
@@ -52,7 +53,7 @@ Description-Content-Type: text/markdown
52
53
 
53
54
  <p align="center"><b>API monitoring made easy.</b></p>
54
55
 
55
- <p align="center"><i>Apitally is a simple and affordable API monitoring solution with a focus on data privacy. It is easy to set up and use for new and existing API projects using Python or Node.js.</i></p>
56
+ <p align="center"><i>Apitally is a simple and affordable API monitoring tool with a focus on data privacy.<br>It is super easy to use for API projects in Python or Node.js and never collects sensitive data.</i></p>
56
57
 
57
58
  <p align="center">🔗 <b><a href="https://apitally.io" target="_blank">apitally.io</a></b></p>
58
59
 
@@ -95,8 +96,8 @@ example:
95
96
  pip install apitally[fastapi]
96
97
  ```
97
98
 
98
- The available extras are: `fastapi`, `starlette`, `flask`, `django` and
99
- `litestar`.
99
+ The available extras are: `fastapi`, `flask`, `django_rest_framework`,
100
+ `django_ninja`, `starlette` and `litestar`.
100
101
 
101
102
  ## Usage
102
103
 
@@ -1,18 +1,19 @@
1
- apitally/__init__.py,sha256=zYiFHqR7JwbvdK9dvKrh-RTNfUqjHUwC4CTcFAPVYLc,22
1
+ apitally/__init__.py,sha256=2KJZDSMOG7KS82AxYOrZ4ZihYxX0wjfUjDsIZh3L024,22
2
2
  apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
3
  apitally/client/asyncio.py,sha256=uR5JlH37G6gZvAJ7A1gYOGkjn3zjC-4I6avA1fncXHs,4433
4
4
  apitally/client/base.py,sha256=E_yUTItAtZWPOx80K3Pm55CNBgF_NZ6RMyXZTnjSV9c,8511
5
5
  apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
6
6
  apitally/client/threading.py,sha256=ihQzUStrSQFynpqXgFpseAXrHuc5Et1QvG-YHlzqDr8,4831
7
- apitally/django.py,sha256=PTKRWd--vJucgHPs2XDixIAQkqzOfjToipZfZBbIlNA,10081
7
+ apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
8
+ apitally/django.py,sha256=SHSM00eRsvoJC2d4c4tK6gctKLnxLfrw4LoZ4CWPm4U,12124
8
9
  apitally/django_ninja.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
9
10
  apitally/django_rest_framework.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
10
11
  apitally/fastapi.py,sha256=Q3n2bVREKQ_V_2yCQ48ngPtr-NJxDskpT_l20xhSbpM,85
11
- apitally/flask.py,sha256=-R0MP72ufO3v0p30JBU9asODWtuOU3FOCl5iY-kTSzw,5099
12
- apitally/litestar.py,sha256=rOQE0gsqDifwZUw_SGMBB1KIV0oAh3ydiJWnpTTC2sI,7504
12
+ apitally/flask.py,sha256=xxyrHchMiPuEMP3c_Us_niJn9K7x87RDvxD5GtntEvU,4769
13
+ apitally/litestar.py,sha256=Pl1tEbxve3vqrdflWdvZjPFrEVxNIr6NjuQVf4YSpzY,7171
13
14
  apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- apitally/starlette.py,sha256=VlLWOR9rzNlI9IjFbQ86APaWanURerIt52tX6bRGzCo,7814
15
- apitally-0.6.3.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
16
- apitally-0.6.3.dist-info/METADATA,sha256=GvqjHhr__OPd3vV06AvjIbMPYHMPBmZWtA2dx45_-b4,6610
17
- apitally-0.6.3.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
18
- apitally-0.6.3.dist-info/RECORD,,
15
+ apitally/starlette.py,sha256=9VKGdNuKPrRcnQv7GeyV5cLa7TpgidxMVnAKSxMsWjI,7345
16
+ apitally-0.7.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
17
+ apitally-0.7.1.dist-info/METADATA,sha256=SO16kB-1EwZGAm28P4m4E2EMltFfmKUy4uF44X4LB30,6736
18
+ apitally-0.7.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
19
+ apitally-0.7.1.dist-info/RECORD,,