apitally 0.10.1__py3-none-any.whl → 0.11.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/__init__.py +1 -1
- apitally/client/asyncio.py +39 -37
- apitally/client/base.py +79 -19
- apitally/client/threading.py +37 -35
- apitally/django.py +38 -28
- apitally/django_ninja.py +2 -2
- apitally/django_rest_framework.py +2 -2
- apitally/fastapi.py +2 -2
- apitally/flask.py +38 -21
- apitally/litestar.py +24 -15
- apitally/starlette.py +38 -25
- {apitally-0.10.1.dist-info → apitally-0.11.0.dist-info}/METADATA +1 -1
- apitally-0.11.0.dist-info/RECORD +19 -0
- apitally-0.10.1.dist-info/RECORD +0 -19
- {apitally-0.10.1.dist-info → apitally-0.11.0.dist-info}/LICENSE +0 -0
- {apitally-0.10.1.dist-info → apitally-0.11.0.dist-info}/WHEEL +0 -0
apitally/__init__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = "0.
|
1
|
+
__version__ = "0.11.0"
|
apitally/client/asyncio.py
CHANGED
@@ -29,7 +29,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
29
29
|
super().__init__(client_id=client_id, env=env)
|
30
30
|
self._stop_sync_loop = False
|
31
31
|
self._sync_loop_task: Optional[asyncio.Task[Any]] = None
|
32
|
-
self.
|
32
|
+
self._sync_data_queue: asyncio.Queue[Tuple[float, Dict[str, Any]]] = asyncio.Queue()
|
33
33
|
|
34
34
|
def get_http_client(self) -> httpx.AsyncClient:
|
35
35
|
return httpx.AsyncClient(base_url=self.hub_url, timeout=REQUEST_TIMEOUT)
|
@@ -44,14 +44,14 @@ class ApitallyClient(ApitallyClientBase):
|
|
44
44
|
try:
|
45
45
|
time_start = time.perf_counter()
|
46
46
|
async with self.get_http_client() as client:
|
47
|
-
tasks = [self.
|
48
|
-
if not self.
|
49
|
-
tasks.append(self.
|
47
|
+
tasks = [self.send_sync_data(client)]
|
48
|
+
if not self._startup_data_sent and not first_iteration:
|
49
|
+
tasks.append(self.send_startup_data(client))
|
50
50
|
await asyncio.gather(*tasks)
|
51
51
|
time_elapsed = time.perf_counter() - time_start
|
52
52
|
await asyncio.sleep(self.sync_interval - time_elapsed)
|
53
|
-
except Exception
|
54
|
-
logger.exception(
|
53
|
+
except Exception: # pragma: no cover
|
54
|
+
logger.exception("An error occurred during sync with Apitally hub")
|
55
55
|
first_iteration = False
|
56
56
|
|
57
57
|
def stop_sync_loop(self) -> None:
|
@@ -60,57 +60,59 @@ class ApitallyClient(ApitallyClientBase):
|
|
60
60
|
async def handle_shutdown(self) -> None:
|
61
61
|
if self._sync_loop_task is not None:
|
62
62
|
self._sync_loop_task.cancel()
|
63
|
-
# Send any remaining
|
63
|
+
# Send any remaining data before exiting
|
64
64
|
async with self.get_http_client() as client:
|
65
|
-
await self.
|
65
|
+
await self.send_sync_data(client)
|
66
66
|
|
67
|
-
def
|
68
|
-
self.
|
69
|
-
self.
|
70
|
-
asyncio.create_task(self.
|
67
|
+
def set_startup_data(self, data: Dict[str, Any]) -> None:
|
68
|
+
self._startup_data_sent = False
|
69
|
+
self._startup_data = self.add_uuids_to_data(data)
|
70
|
+
asyncio.create_task(self._set_startup_data_task())
|
71
71
|
|
72
|
-
async def
|
72
|
+
async def _set_startup_data_task(self) -> None:
|
73
73
|
async with self.get_http_client() as client:
|
74
|
-
await self.
|
74
|
+
await self.send_startup_data(client)
|
75
75
|
|
76
|
-
async def
|
77
|
-
if self.
|
78
|
-
await self.
|
76
|
+
async def send_startup_data(self, client: httpx.AsyncClient) -> None:
|
77
|
+
if self._startup_data is not None:
|
78
|
+
await self._send_startup_data(client, self._startup_data)
|
79
79
|
|
80
|
-
async def
|
81
|
-
|
82
|
-
self.
|
80
|
+
async def send_sync_data(self, client: httpx.AsyncClient) -> None:
|
81
|
+
data = self.get_sync_data()
|
82
|
+
self._sync_data_queue.put_nowait((time.time(), data))
|
83
83
|
|
84
84
|
failed_items = []
|
85
|
-
while not self.
|
86
|
-
|
85
|
+
while not self._sync_data_queue.empty():
|
86
|
+
timestamp, data = self._sync_data_queue.get_nowait()
|
87
87
|
try:
|
88
|
-
if (time_offset := time.time() -
|
89
|
-
|
90
|
-
await self.
|
91
|
-
self.
|
88
|
+
if (time_offset := time.time() - timestamp) <= MAX_QUEUE_TIME:
|
89
|
+
data["time_offset"] = time_offset
|
90
|
+
await self._send_sync_data(client, data)
|
91
|
+
self._sync_data_queue.task_done()
|
92
92
|
except httpx.HTTPError:
|
93
|
-
failed_items.append((
|
93
|
+
failed_items.append((timestamp, data))
|
94
94
|
for item in failed_items:
|
95
|
-
self.
|
95
|
+
self._sync_data_queue.put_nowait(item)
|
96
96
|
|
97
97
|
@retry(raise_on_giveup=False)
|
98
|
-
async def
|
99
|
-
logger.debug("Sending
|
100
|
-
response = await client.post(url="/
|
98
|
+
async def _send_startup_data(self, client: httpx.AsyncClient, data: Dict[str, Any]) -> None:
|
99
|
+
logger.debug("Sending startup data to Apitally hub")
|
100
|
+
response = await client.post(url="/startup", json=data, timeout=REQUEST_TIMEOUT)
|
101
101
|
self._handle_hub_response(response)
|
102
|
-
self.
|
103
|
-
self.
|
102
|
+
self._startup_data_sent = True
|
103
|
+
self._startup_data = None
|
104
104
|
|
105
105
|
@retry()
|
106
|
-
async def
|
107
|
-
logger.debug("
|
108
|
-
response = await client.post(url="/
|
106
|
+
async def _send_sync_data(self, client: httpx.AsyncClient, data: Dict[str, Any]) -> None:
|
107
|
+
logger.debug("Synchronizing data with Apitally hub")
|
108
|
+
response = await client.post(url="/sync", json=data)
|
109
109
|
self._handle_hub_response(response)
|
110
110
|
|
111
111
|
def _handle_hub_response(self, response: httpx.Response) -> None:
|
112
112
|
if response.status_code == 404:
|
113
113
|
self.stop_sync_loop()
|
114
|
-
logger.error(
|
114
|
+
logger.error("Invalid Apitally client ID: %s", self.client_id)
|
115
|
+
elif response.status_code == 422:
|
116
|
+
logger.error("Received validation error from Apitally hub: %s", response.json())
|
115
117
|
else:
|
116
118
|
response.raise_for_status()
|
apitally/client/base.py
CHANGED
@@ -11,7 +11,7 @@ from abc import ABC
|
|
11
11
|
from collections import Counter
|
12
12
|
from dataclasses import dataclass
|
13
13
|
from math import floor
|
14
|
-
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, cast
|
14
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union, cast
|
15
15
|
from uuid import UUID, uuid4
|
16
16
|
|
17
17
|
from apitally.client.logging import get_logger
|
@@ -20,7 +20,7 @@ from apitally.client.logging import get_logger
|
|
20
20
|
logger = get_logger(__name__)
|
21
21
|
|
22
22
|
HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io"
|
23
|
-
HUB_VERSION = "
|
23
|
+
HUB_VERSION = "v2"
|
24
24
|
REQUEST_TIMEOUT = 10
|
25
25
|
MAX_QUEUE_TIME = 3600
|
26
26
|
SYNC_INTERVAL = 60
|
@@ -59,9 +59,10 @@ class ApitallyClientBase(ABC):
|
|
59
59
|
self.request_counter = RequestCounter()
|
60
60
|
self.validation_error_counter = ValidationErrorCounter()
|
61
61
|
self.server_error_counter = ServerErrorCounter()
|
62
|
+
self.consumer_registry = ConsumerRegistry()
|
62
63
|
|
63
|
-
self.
|
64
|
-
self.
|
64
|
+
self._startup_data: Optional[Dict[str, Any]] = None
|
65
|
+
self._startup_data_sent = False
|
65
66
|
self._started_at = time.time()
|
66
67
|
|
67
68
|
@classmethod
|
@@ -80,25 +81,22 @@ class ApitallyClientBase(ABC):
|
|
80
81
|
def hub_url(self) -> str:
|
81
82
|
return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}"
|
82
83
|
|
83
|
-
def
|
84
|
-
|
84
|
+
def add_uuids_to_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
85
|
+
data_with_uuids = {
|
85
86
|
"instance_uuid": self.instance_uuid,
|
86
87
|
"message_uuid": str(uuid4()),
|
87
88
|
}
|
88
|
-
|
89
|
-
return
|
90
|
-
|
91
|
-
def
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
"
|
97
|
-
"message_uuid": str(uuid4()),
|
98
|
-
"requests": requests,
|
99
|
-
"validation_errors": validation_errors,
|
100
|
-
"server_errors": server_errors,
|
89
|
+
data_with_uuids.update(data)
|
90
|
+
return data_with_uuids
|
91
|
+
|
92
|
+
def get_sync_data(self) -> Dict[str, Any]:
|
93
|
+
data = {
|
94
|
+
"requests": self.request_counter.get_and_reset_requests(),
|
95
|
+
"validation_errors": self.validation_error_counter.get_and_reset_validation_errors(),
|
96
|
+
"server_errors": self.server_error_counter.get_and_reset_server_errors(),
|
97
|
+
"consumers": self.consumer_registry.get_and_reset_updated_consumers(),
|
101
98
|
}
|
99
|
+
return self.add_uuids_to_data(data)
|
102
100
|
|
103
101
|
|
104
102
|
@dataclass(frozen=True)
|
@@ -334,3 +332,65 @@ class ServerErrorCounter:
|
|
334
332
|
lines.append(line)
|
335
333
|
length += len(line)
|
336
334
|
return "".join(lines[::-1]).strip()
|
335
|
+
|
336
|
+
|
337
|
+
class Consumer:
|
338
|
+
def __init__(self, identifier: str, name: Optional[str] = None, group: Optional[str] = None) -> None:
|
339
|
+
self.identifier = str(identifier).strip()[:128]
|
340
|
+
self.name = str(name).strip()[:64] if name else None
|
341
|
+
self.group = str(group).strip()[:64] if group else None
|
342
|
+
|
343
|
+
@classmethod
|
344
|
+
def from_string_or_object(cls, consumer: Optional[Union[str, Consumer]]) -> Optional[Consumer]:
|
345
|
+
if not consumer:
|
346
|
+
return None
|
347
|
+
if isinstance(consumer, Consumer):
|
348
|
+
return consumer
|
349
|
+
consumer = str(consumer).strip()
|
350
|
+
if not consumer:
|
351
|
+
return None
|
352
|
+
return cls(identifier=consumer)
|
353
|
+
|
354
|
+
def update(self, name: str | None = None, group: str | None = None) -> bool:
|
355
|
+
name = str(name).strip()[:64] if name else None
|
356
|
+
group = str(group).strip()[:64] if group else None
|
357
|
+
updated = False
|
358
|
+
if name and name != self.name:
|
359
|
+
self.name = name
|
360
|
+
updated = True
|
361
|
+
if group and group != self.group:
|
362
|
+
self.group = group
|
363
|
+
updated = True
|
364
|
+
return updated
|
365
|
+
|
366
|
+
|
367
|
+
class ConsumerRegistry:
|
368
|
+
def __init__(self) -> None:
|
369
|
+
self.consumers: Dict[str, Consumer] = {}
|
370
|
+
self.updated: Set[str] = set()
|
371
|
+
self._lock = threading.Lock()
|
372
|
+
|
373
|
+
def add_or_update_consumer(self, consumer: Optional[Consumer]) -> None:
|
374
|
+
if not consumer or (not consumer.name and not consumer.group):
|
375
|
+
return # Only register consumers with name or group set
|
376
|
+
with self._lock:
|
377
|
+
if consumer.identifier not in self.consumers:
|
378
|
+
self.consumers[consumer.identifier] = consumer
|
379
|
+
self.updated.add(consumer.identifier)
|
380
|
+
elif self.consumers[consumer.identifier].update(name=consumer.name, group=consumer.group):
|
381
|
+
self.updated.add(consumer.identifier)
|
382
|
+
|
383
|
+
def get_and_reset_updated_consumers(self) -> List[Dict[str, Any]]:
|
384
|
+
data: List[Dict[str, Any]] = []
|
385
|
+
with self._lock:
|
386
|
+
for identifier in self.updated:
|
387
|
+
if consumer := self.consumers.get(identifier):
|
388
|
+
data.append(
|
389
|
+
{
|
390
|
+
"identifier": consumer.identifier,
|
391
|
+
"name": str(consumer.name)[:64] if consumer.name else None,
|
392
|
+
"group": str(consumer.group)[:64] if consumer.group else None,
|
393
|
+
}
|
394
|
+
)
|
395
|
+
self.updated.clear()
|
396
|
+
return data
|
apitally/client/threading.py
CHANGED
@@ -46,7 +46,7 @@ class ApitallyClient(ApitallyClientBase):
|
|
46
46
|
super().__init__(client_id=client_id, env=env)
|
47
47
|
self._thread: Optional[Thread] = None
|
48
48
|
self._stop_sync_loop = Event()
|
49
|
-
self.
|
49
|
+
self._sync_data_queue: queue.Queue[Tuple[float, Dict[str, Any]]] = queue.Queue()
|
50
50
|
|
51
51
|
def start_sync_loop(self) -> None:
|
52
52
|
self._stop_sync_loop.clear()
|
@@ -63,17 +63,17 @@ class ApitallyClient(ApitallyClientBase):
|
|
63
63
|
now = time.time()
|
64
64
|
if (now - last_sync_time) >= self.sync_interval:
|
65
65
|
with requests.Session() as session:
|
66
|
-
if not self.
|
67
|
-
self.
|
68
|
-
self.
|
66
|
+
if not self._startup_data_sent and last_sync_time > 0: # not on first sync
|
67
|
+
self.send_startup_data(session)
|
68
|
+
self.send_sync_data(session)
|
69
69
|
last_sync_time = now
|
70
70
|
time.sleep(1)
|
71
|
-
except Exception
|
72
|
-
logger.exception(
|
71
|
+
except Exception: # pragma: no cover
|
72
|
+
logger.exception("An error occurred during sync with Apitally hub")
|
73
73
|
finally:
|
74
|
-
# Send any remaining
|
74
|
+
# Send any remaining data before exiting
|
75
75
|
with requests.Session() as session:
|
76
|
-
self.
|
76
|
+
self.send_sync_data(session)
|
77
77
|
|
78
78
|
def stop_sync_loop(self) -> None:
|
79
79
|
self._stop_sync_loop.set()
|
@@ -81,50 +81,52 @@ class ApitallyClient(ApitallyClientBase):
|
|
81
81
|
self._thread.join()
|
82
82
|
self._thread = None
|
83
83
|
|
84
|
-
def
|
85
|
-
self.
|
86
|
-
self.
|
84
|
+
def set_startup_data(self, data: Dict[str, Any]) -> None:
|
85
|
+
self._startup_data_sent = False
|
86
|
+
self._startup_data = self.add_uuids_to_data(data)
|
87
87
|
with requests.Session() as session:
|
88
|
-
self.
|
88
|
+
self.send_startup_data(session)
|
89
89
|
|
90
|
-
def
|
91
|
-
if self.
|
92
|
-
self.
|
90
|
+
def send_startup_data(self, session: requests.Session) -> None:
|
91
|
+
if self._startup_data is not None:
|
92
|
+
self._send_startup_data(session, self._startup_data)
|
93
93
|
|
94
|
-
def
|
95
|
-
|
96
|
-
self.
|
94
|
+
def send_sync_data(self, session: requests.Session) -> None:
|
95
|
+
data = self.get_sync_data()
|
96
|
+
self._sync_data_queue.put_nowait((time.time(), data))
|
97
97
|
|
98
98
|
failed_items = []
|
99
|
-
while not self.
|
100
|
-
|
99
|
+
while not self._sync_data_queue.empty():
|
100
|
+
timestamp, data = self._sync_data_queue.get_nowait()
|
101
101
|
try:
|
102
|
-
if (time_offset := time.time() -
|
103
|
-
|
104
|
-
self.
|
105
|
-
self.
|
102
|
+
if (time_offset := time.time() - timestamp) <= MAX_QUEUE_TIME:
|
103
|
+
data["time_offset"] = time_offset
|
104
|
+
self._send_sync_data(session, data)
|
105
|
+
self._sync_data_queue.task_done()
|
106
106
|
except requests.RequestException:
|
107
|
-
failed_items.append((
|
107
|
+
failed_items.append((timestamp, data))
|
108
108
|
for item in failed_items:
|
109
|
-
self.
|
109
|
+
self._sync_data_queue.put_nowait(item)
|
110
110
|
|
111
111
|
@retry(raise_on_giveup=False)
|
112
|
-
def
|
113
|
-
logger.debug("Sending
|
114
|
-
response = session.post(url=f"{self.hub_url}/
|
112
|
+
def _send_startup_data(self, session: requests.Session, data: Dict[str, Any]) -> None:
|
113
|
+
logger.debug("Sending startup data to Apitally hub")
|
114
|
+
response = session.post(url=f"{self.hub_url}/startup", json=data, timeout=REQUEST_TIMEOUT)
|
115
115
|
self._handle_hub_response(response)
|
116
|
-
self.
|
117
|
-
self.
|
116
|
+
self._startup_data_sent = True
|
117
|
+
self._startup_data = None
|
118
118
|
|
119
119
|
@retry()
|
120
|
-
def
|
121
|
-
logger.debug("
|
122
|
-
response = session.post(url=f"{self.hub_url}/
|
120
|
+
def _send_sync_data(self, session: requests.Session, data: Dict[str, Any]) -> None:
|
121
|
+
logger.debug("Synchronizing data with Apitally hub")
|
122
|
+
response = session.post(url=f"{self.hub_url}/sync", json=data, timeout=REQUEST_TIMEOUT)
|
123
123
|
self._handle_hub_response(response)
|
124
124
|
|
125
125
|
def _handle_hub_response(self, response: requests.Response) -> None:
|
126
126
|
if response.status_code == 404:
|
127
127
|
self.stop_sync_loop()
|
128
|
-
logger.error(
|
128
|
+
logger.error("Invalid Apitally client ID: %s", self.client_id)
|
129
|
+
elif response.status_code == 422:
|
130
|
+
logger.error("Received validation error from Apitally hub: %s", response.json())
|
129
131
|
else:
|
130
132
|
response.raise_for_status()
|
apitally/django.py
CHANGED
@@ -7,11 +7,13 @@ import time
|
|
7
7
|
from dataclasses import dataclass
|
8
8
|
from importlib import import_module
|
9
9
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Union
|
10
|
+
from warnings import warn
|
10
11
|
|
11
12
|
from django.conf import settings
|
12
13
|
from django.urls import URLPattern, URLResolver, get_resolver
|
13
14
|
from django.utils.module_loading import import_string
|
14
15
|
|
16
|
+
from apitally.client.base import Consumer as ApitallyConsumer
|
15
17
|
from apitally.client.logging import get_logger
|
16
18
|
from apitally.client.threading import ApitallyClient
|
17
19
|
from apitally.common import get_versions
|
@@ -22,7 +24,7 @@ if TYPE_CHECKING:
|
|
22
24
|
from ninja import NinjaAPI
|
23
25
|
|
24
26
|
|
25
|
-
__all__ = ["ApitallyMiddleware"]
|
27
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
26
28
|
logger = get_logger(__name__)
|
27
29
|
|
28
30
|
|
@@ -31,7 +33,7 @@ class ApitallyMiddlewareConfig:
|
|
31
33
|
client_id: str
|
32
34
|
env: str
|
33
35
|
app_version: Optional[str]
|
34
|
-
identify_consumer_callback: Optional[Callable[[HttpRequest],
|
36
|
+
identify_consumer_callback: Optional[Callable[[HttpRequest], Union[str, ApitallyConsumer, None]]]
|
35
37
|
urlconfs: List[Optional[str]]
|
36
38
|
|
37
39
|
|
@@ -61,8 +63,8 @@ class ApitallyMiddleware:
|
|
61
63
|
|
62
64
|
self.client = ApitallyClient(client_id=self.config.client_id, env=self.config.env)
|
63
65
|
self.client.start_sync_loop()
|
64
|
-
self.client.
|
65
|
-
|
66
|
+
self.client.set_startup_data(
|
67
|
+
_get_startup_data(
|
66
68
|
app_version=self.config.app_version,
|
67
69
|
urlconfs=self.config.urlconfs,
|
68
70
|
)
|
@@ -93,10 +95,16 @@ class ApitallyMiddleware:
|
|
93
95
|
response_time = time.perf_counter() - start_time
|
94
96
|
path = self.get_path(request)
|
95
97
|
if request.method is not None and path is not None:
|
96
|
-
|
98
|
+
try:
|
99
|
+
consumer = self.get_consumer(request)
|
100
|
+
consumer_identifier = consumer.identifier if consumer else None
|
101
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
102
|
+
except Exception: # pragma: no cover
|
103
|
+
logger.exception("Failed to get consumer for request")
|
104
|
+
consumer_identifier = None
|
97
105
|
try:
|
98
106
|
self.client.request_counter.add_request(
|
99
|
-
consumer=
|
107
|
+
consumer=consumer_identifier,
|
100
108
|
method=request.method,
|
101
109
|
path=path,
|
102
110
|
status_code=response.status_code,
|
@@ -119,7 +127,7 @@ class ApitallyMiddleware:
|
|
119
127
|
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
120
128
|
# Log Django Ninja / Pydantic validation errors
|
121
129
|
self.client.validation_error_counter.add_validation_errors(
|
122
|
-
consumer=
|
130
|
+
consumer=consumer_identifier,
|
123
131
|
method=request.method,
|
124
132
|
path=path,
|
125
133
|
detail=body["detail"],
|
@@ -129,7 +137,7 @@ class ApitallyMiddleware:
|
|
129
137
|
if response.status_code == 500 and hasattr(request, "unhandled_exception"):
|
130
138
|
try:
|
131
139
|
self.client.server_error_counter.add_server_error(
|
132
|
-
consumer=
|
140
|
+
consumer=consumer_identifier,
|
133
141
|
method=request.method,
|
134
142
|
path=path,
|
135
143
|
exception=getattr(request, "unhandled_exception"),
|
@@ -162,35 +170,37 @@ class ApitallyMiddleware:
|
|
162
170
|
logger.exception("Failed to get path for request")
|
163
171
|
return None
|
164
172
|
|
165
|
-
def get_consumer(self, request: HttpRequest) -> Optional[
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
173
|
+
def get_consumer(self, request: HttpRequest) -> Optional[ApitallyConsumer]:
|
174
|
+
if hasattr(request, "apitally_consumer") and request.apitally_consumer:
|
175
|
+
return ApitallyConsumer.from_string_or_object(request.apitally_consumer)
|
176
|
+
if hasattr(request, "consumer_identifier") and request.consumer_identifier:
|
177
|
+
# Keeping this for legacy support
|
178
|
+
warn(
|
179
|
+
"Providing a consumer identifier via `request.consumer_identifier` is deprecated, "
|
180
|
+
"use `request.apitally_consumer` instead.",
|
181
|
+
DeprecationWarning,
|
182
|
+
)
|
183
|
+
return ApitallyConsumer.from_string_or_object(request.consumer_identifier)
|
184
|
+
if self.config is not None and self.config.identify_consumer_callback is not None:
|
185
|
+
consumer = self.config.identify_consumer_callback(request)
|
186
|
+
return ApitallyConsumer.from_string_or_object(consumer)
|
177
187
|
return None
|
178
188
|
|
179
189
|
|
180
|
-
def
|
181
|
-
|
190
|
+
def _get_startup_data(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]:
|
191
|
+
data: Dict[str, Any] = {}
|
182
192
|
try:
|
183
|
-
|
193
|
+
data["paths"] = _get_paths(urlconfs)
|
184
194
|
except Exception: # pragma: no cover
|
185
|
-
|
195
|
+
data["paths"] = []
|
186
196
|
logger.exception("Failed to get paths")
|
187
197
|
try:
|
188
|
-
|
198
|
+
data["openapi"] = _get_openapi(urlconfs)
|
189
199
|
except Exception: # pragma: no cover
|
190
200
|
logger.exception("Failed to get OpenAPI schema")
|
191
|
-
|
192
|
-
|
193
|
-
return
|
201
|
+
data["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version)
|
202
|
+
data["client"] = "python:django"
|
203
|
+
return data
|
194
204
|
|
195
205
|
|
196
206
|
def _get_openapi(urlconfs: List[Optional[str]]) -> Optional[str]:
|
apitally/django_ninja.py
CHANGED
apitally/fastapi.py
CHANGED
apitally/flask.py
CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
3
3
|
import time
|
4
4
|
from threading import Timer
|
5
5
|
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple
|
6
|
+
from warnings import warn
|
6
7
|
|
7
8
|
from flask import Flask, g
|
8
9
|
from flask.wrappers import Response
|
@@ -10,6 +11,7 @@ from werkzeug.datastructures import Headers
|
|
10
11
|
from werkzeug.exceptions import NotFound
|
11
12
|
from werkzeug.test import Client
|
12
13
|
|
14
|
+
from apitally.client.base import Consumer as ApitallyConsumer
|
13
15
|
from apitally.client.threading import ApitallyClient
|
14
16
|
from apitally.common import get_versions
|
15
17
|
|
@@ -19,7 +21,7 @@ if TYPE_CHECKING:
|
|
19
21
|
from werkzeug.routing.map import Map
|
20
22
|
|
21
23
|
|
22
|
-
__all__ = ["ApitallyMiddleware"]
|
24
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
23
25
|
|
24
26
|
|
25
27
|
class ApitallyMiddleware:
|
@@ -38,16 +40,20 @@ class ApitallyMiddleware:
|
|
38
40
|
self.patch_handle_exception()
|
39
41
|
self.client = ApitallyClient(client_id=client_id, env=env)
|
40
42
|
self.client.start_sync_loop()
|
41
|
-
self.
|
43
|
+
self.delayed_set_startup_data(app_version, openapi_url)
|
42
44
|
|
43
|
-
def
|
45
|
+
def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
|
44
46
|
# Short delay to allow app routes to be registered first
|
45
|
-
timer = Timer(
|
47
|
+
timer = Timer(
|
48
|
+
1.0,
|
49
|
+
self._delayed_set_startup_data,
|
50
|
+
kwargs={"app_version": app_version, "openapi_url": openapi_url},
|
51
|
+
)
|
46
52
|
timer.start()
|
47
53
|
|
48
|
-
def
|
49
|
-
|
50
|
-
self.client.
|
54
|
+
def _delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
|
55
|
+
data = _get_startup_data(self.app, app_version, openapi_url)
|
56
|
+
self.client.set_startup_data(data)
|
51
57
|
|
52
58
|
def __call__(self, environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
53
59
|
status_code = 200
|
@@ -88,8 +94,11 @@ class ApitallyMiddleware:
|
|
88
94
|
) -> None:
|
89
95
|
rule, is_handled_path = self.get_rule(environ)
|
90
96
|
if is_handled_path or not self.filter_unhandled_paths:
|
97
|
+
consumer = self.get_consumer()
|
98
|
+
consumer_identifier = consumer.identifier if consumer else None
|
99
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
91
100
|
self.client.request_counter.add_request(
|
92
|
-
consumer=
|
101
|
+
consumer=consumer_identifier,
|
93
102
|
method=environ["REQUEST_METHOD"],
|
94
103
|
path=rule,
|
95
104
|
status_code=status_code,
|
@@ -99,7 +108,7 @@ class ApitallyMiddleware:
|
|
99
108
|
)
|
100
109
|
if status_code == 500 and "unhandled_exception" in g:
|
101
110
|
self.client.server_error_counter.add_server_error(
|
102
|
-
consumer=
|
111
|
+
consumer=consumer_identifier,
|
103
112
|
method=environ["REQUEST_METHOD"],
|
104
113
|
path=rule,
|
105
114
|
exception=g.unhandled_exception,
|
@@ -114,23 +123,31 @@ class ApitallyMiddleware:
|
|
114
123
|
except NotFound:
|
115
124
|
return environ["PATH_INFO"], False
|
116
125
|
|
117
|
-
def get_consumer(self) -> Optional[
|
118
|
-
if "apitally_consumer" in g:
|
119
|
-
return
|
120
|
-
if "consumer_identifier" in g
|
121
|
-
|
126
|
+
def get_consumer(self) -> Optional[ApitallyConsumer]:
|
127
|
+
if "apitally_consumer" in g and g.apitally_consumer:
|
128
|
+
return ApitallyConsumer.from_string_or_object(g.apitally_consumer)
|
129
|
+
if "consumer_identifier" in g and g.consumer_identifier:
|
130
|
+
# Keeping this for legacy support
|
131
|
+
warn(
|
132
|
+
"Providing a consumer identifier via `g.consumer_identifier` is deprecated, "
|
133
|
+
"use `g.apitally_consumer` instead.",
|
134
|
+
DeprecationWarning,
|
135
|
+
)
|
136
|
+
return ApitallyConsumer.from_string_or_object(g.consumer_identifier)
|
122
137
|
return None
|
123
138
|
|
124
139
|
|
125
|
-
def
|
126
|
-
|
140
|
+
def _get_startup_data(
|
141
|
+
app: Flask, app_version: Optional[str] = None, openapi_url: Optional[str] = None
|
142
|
+
) -> Dict[str, Any]:
|
143
|
+
data: Dict[str, Any] = {}
|
127
144
|
if openapi_url and (openapi := _get_openapi(app, openapi_url)):
|
128
|
-
|
145
|
+
data["openapi"] = openapi
|
129
146
|
if paths := _get_paths(app.url_map):
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
return
|
147
|
+
data["paths"] = paths
|
148
|
+
data["versions"] = get_versions("flask", app_version=app_version)
|
149
|
+
data["client"] = "python:flask"
|
150
|
+
return data
|
134
151
|
|
135
152
|
|
136
153
|
def _get_paths(url_map: Map) -> List[Dict[str, str]]:
|
apitally/litestar.py
CHANGED
@@ -1,7 +1,8 @@
|
|
1
1
|
import contextlib
|
2
2
|
import json
|
3
3
|
import time
|
4
|
-
from typing import Callable, Dict, List, Optional
|
4
|
+
from typing import Callable, Dict, List, Optional, Union
|
5
|
+
from warnings import warn
|
5
6
|
|
6
7
|
from litestar.app import DEFAULT_OPENAPI_CONFIG, Litestar
|
7
8
|
from litestar.config.app import AppConfig
|
@@ -13,10 +14,11 @@ from litestar.plugins import InitPluginProtocol
|
|
13
14
|
from litestar.types import ASGIApp, Message, Receive, Scope, Send
|
14
15
|
|
15
16
|
from apitally.client.asyncio import ApitallyClient
|
17
|
+
from apitally.client.base import Consumer as ApitallyConsumer
|
16
18
|
from apitally.common import get_versions
|
17
19
|
|
18
20
|
|
19
|
-
__all__ = ["ApitallyPlugin"]
|
21
|
+
__all__ = ["ApitallyPlugin", "ApitallyConsumer"]
|
20
22
|
|
21
23
|
|
22
24
|
class ApitallyPlugin(InitPluginProtocol):
|
@@ -26,7 +28,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
26
28
|
env: str = "dev",
|
27
29
|
app_version: Optional[str] = None,
|
28
30
|
filter_openapi_paths: bool = True,
|
29
|
-
identify_consumer_callback: Optional[Callable[[Request],
|
31
|
+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
|
30
32
|
) -> None:
|
31
33
|
self.client = ApitallyClient(client_id=client_id, env=env)
|
32
34
|
self.app_version = app_version
|
@@ -50,13 +52,13 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
50
52
|
elif openapi_config.path is not None:
|
51
53
|
self.openapi_path = openapi_config.path
|
52
54
|
|
53
|
-
|
55
|
+
data = {
|
54
56
|
"openapi": _get_openapi(app),
|
55
57
|
"paths": [route for route in _get_routes(app) if not self.filter_path(route["path"])],
|
56
58
|
"versions": get_versions("litestar", app_version=self.app_version),
|
57
59
|
"client": "python:litestar",
|
58
60
|
}
|
59
|
-
self.client.
|
61
|
+
self.client.set_startup_data(data)
|
60
62
|
self.client.start_sync_loop()
|
61
63
|
|
62
64
|
def after_exception(self, exception: Exception, scope: Scope) -> None:
|
@@ -109,8 +111,10 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
109
111
|
if path is None or self.filter_path(path):
|
110
112
|
return
|
111
113
|
consumer = self.get_consumer(request)
|
114
|
+
consumer_identifier = consumer.identifier if consumer else None
|
115
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
112
116
|
self.client.request_counter.add_request(
|
113
|
-
consumer=
|
117
|
+
consumer=consumer_identifier,
|
114
118
|
method=request.method,
|
115
119
|
path=path,
|
116
120
|
status_code=response_status,
|
@@ -130,7 +134,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
130
134
|
and isinstance(parsed_body["extra"], list)
|
131
135
|
):
|
132
136
|
self.client.validation_error_counter.add_validation_errors(
|
133
|
-
consumer=
|
137
|
+
consumer=consumer_identifier,
|
134
138
|
method=request.method,
|
135
139
|
path=path,
|
136
140
|
detail=[
|
@@ -145,7 +149,7 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
145
149
|
)
|
146
150
|
if response_status == 500 and "exception" in request.state:
|
147
151
|
self.client.server_error_counter.add_server_error(
|
148
|
-
consumer=
|
152
|
+
consumer=consumer_identifier,
|
149
153
|
method=request.method,
|
150
154
|
path=path,
|
151
155
|
exception=request.state["exception"],
|
@@ -167,15 +171,20 @@ class ApitallyPlugin(InitPluginProtocol):
|
|
167
171
|
return path == self.openapi_path or path.startswith(self.openapi_path + "/")
|
168
172
|
return False # pragma: no cover
|
169
173
|
|
170
|
-
def get_consumer(self, request: Request) -> Optional[
|
171
|
-
if hasattr(request.state, "apitally_consumer"):
|
172
|
-
return
|
173
|
-
if hasattr(request.state, "consumer_identifier")
|
174
|
-
|
174
|
+
def get_consumer(self, request: Request) -> Optional[ApitallyConsumer]:
|
175
|
+
if hasattr(request.state, "apitally_consumer") and request.state.apitally_consumer:
|
176
|
+
return ApitallyConsumer.from_string_or_object(request.state.apitally_consumer)
|
177
|
+
if hasattr(request.state, "consumer_identifier") and request.state.consumer_identifier:
|
178
|
+
# Keeping this for legacy support
|
179
|
+
warn(
|
180
|
+
"Providing a consumer identifier via `request.state.consumer_identifier` is deprecated, "
|
181
|
+
"use `request.state.apitally_consumer` instead.",
|
182
|
+
DeprecationWarning,
|
183
|
+
)
|
184
|
+
return ApitallyConsumer.from_string_or_object(request.state.consumer_identifier)
|
175
185
|
if self.identify_consumer_callback is not None:
|
176
186
|
consumer = self.identify_consumer_callback(request)
|
177
|
-
|
178
|
-
return str(consumer)
|
187
|
+
return ApitallyConsumer.from_string_or_object(consumer)
|
179
188
|
return None
|
180
189
|
|
181
190
|
|
apitally/starlette.py
CHANGED
@@ -4,6 +4,7 @@ import asyncio
|
|
4
4
|
import json
|
5
5
|
import time
|
6
6
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union
|
7
|
+
from warnings import warn
|
7
8
|
|
8
9
|
from httpx import HTTPStatusError
|
9
10
|
from starlette.concurrency import iterate_in_threadpool
|
@@ -15,6 +16,7 @@ from starlette.testclient import TestClient
|
|
15
16
|
from starlette.types import ASGIApp
|
16
17
|
|
17
18
|
from apitally.client.asyncio import ApitallyClient
|
19
|
+
from apitally.client.base import Consumer as ApitallyConsumer
|
18
20
|
from apitally.common import get_versions
|
19
21
|
|
20
22
|
|
@@ -24,7 +26,7 @@ if TYPE_CHECKING:
|
|
24
26
|
from starlette.responses import Response
|
25
27
|
|
26
28
|
|
27
|
-
__all__ = ["ApitallyMiddleware"]
|
29
|
+
__all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
|
28
30
|
|
29
31
|
|
30
32
|
class ApitallyMiddleware(BaseHTTPMiddleware):
|
@@ -36,23 +38,25 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
36
38
|
app_version: Optional[str] = None,
|
37
39
|
openapi_url: Optional[str] = "/openapi.json",
|
38
40
|
filter_unhandled_paths: bool = True,
|
39
|
-
identify_consumer_callback: Optional[Callable[[Request],
|
41
|
+
identify_consumer_callback: Optional[Callable[[Request], Union[str, ApitallyConsumer, None]]] = None,
|
40
42
|
) -> None:
|
41
43
|
self.filter_unhandled_paths = filter_unhandled_paths
|
42
44
|
self.identify_consumer_callback = identify_consumer_callback
|
43
45
|
self.client = ApitallyClient(client_id=client_id, env=env)
|
44
46
|
self.client.start_sync_loop()
|
45
|
-
self.
|
47
|
+
self.delayed_set_startup_data(app_version, openapi_url)
|
46
48
|
_register_shutdown_handler(app, self.client.handle_shutdown)
|
47
49
|
super().__init__(app)
|
48
50
|
|
49
|
-
def
|
50
|
-
asyncio.create_task(self.
|
51
|
+
def delayed_set_startup_data(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
|
52
|
+
asyncio.create_task(self._delayed_set_startup_data(app_version, openapi_url))
|
51
53
|
|
52
|
-
async def
|
54
|
+
async def _delayed_set_startup_data(
|
55
|
+
self, app_version: Optional[str] = None, openapi_url: Optional[str] = None
|
56
|
+
) -> None:
|
53
57
|
await asyncio.sleep(1.0) # Short delay to allow app routes to be registered first
|
54
|
-
|
55
|
-
self.client.
|
58
|
+
data = _get_startup_data(self.app, app_version, openapi_url)
|
59
|
+
self.client.set_startup_data(data)
|
56
60
|
|
57
61
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
58
62
|
try:
|
@@ -87,8 +91,10 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
87
91
|
path_template, is_handled_path = self.get_path_template(request)
|
88
92
|
if is_handled_path or not self.filter_unhandled_paths:
|
89
93
|
consumer = self.get_consumer(request)
|
94
|
+
consumer_identifier = consumer.identifier if consumer else None
|
95
|
+
self.client.consumer_registry.add_or_update_consumer(consumer)
|
90
96
|
self.client.request_counter.add_request(
|
91
|
-
consumer=
|
97
|
+
consumer=consumer_identifier,
|
92
98
|
method=request.method,
|
93
99
|
path=path_template,
|
94
100
|
status_code=status_code,
|
@@ -105,14 +111,14 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
105
111
|
if isinstance(body, dict) and "detail" in body and isinstance(body["detail"], list):
|
106
112
|
# Log FastAPI / Pydantic validation errors
|
107
113
|
self.client.validation_error_counter.add_validation_errors(
|
108
|
-
consumer=
|
114
|
+
consumer=consumer_identifier,
|
109
115
|
method=request.method,
|
110
116
|
path=path_template,
|
111
117
|
detail=body["detail"],
|
112
118
|
)
|
113
119
|
if status_code == 500 and exception is not None:
|
114
120
|
self.client.server_error_counter.add_server_error(
|
115
|
-
consumer=
|
121
|
+
consumer=consumer_identifier,
|
116
122
|
method=request.method,
|
117
123
|
path=path_template,
|
118
124
|
exception=exception,
|
@@ -141,27 +147,34 @@ class ApitallyMiddleware(BaseHTTPMiddleware):
|
|
141
147
|
return route.path, True
|
142
148
|
return request.url.path, False
|
143
149
|
|
144
|
-
def get_consumer(self, request: Request) -> Optional[
|
145
|
-
if hasattr(request.state, "apitally_consumer"):
|
146
|
-
return
|
147
|
-
if hasattr(request.state, "consumer_identifier")
|
148
|
-
|
150
|
+
def get_consumer(self, request: Request) -> Optional[ApitallyConsumer]:
|
151
|
+
if hasattr(request.state, "apitally_consumer") and request.state.apitally_consumer:
|
152
|
+
return ApitallyConsumer.from_string_or_object(request.state.apitally_consumer)
|
153
|
+
if hasattr(request.state, "consumer_identifier") and request.state.consumer_identifier:
|
154
|
+
# Keeping this for legacy support
|
155
|
+
warn(
|
156
|
+
"Providing a consumer identifier via `request.state.consumer_identifier` is deprecated, "
|
157
|
+
"use `request.state.apitally_consumer` instead.",
|
158
|
+
DeprecationWarning,
|
159
|
+
)
|
160
|
+
return ApitallyConsumer.from_string_or_object(request.state.consumer_identifier)
|
149
161
|
if self.identify_consumer_callback is not None:
|
150
162
|
consumer = self.identify_consumer_callback(request)
|
151
|
-
|
152
|
-
return str(consumer)
|
163
|
+
return ApitallyConsumer.from_string_or_object(consumer)
|
153
164
|
return None
|
154
165
|
|
155
166
|
|
156
|
-
def
|
157
|
-
|
167
|
+
def _get_startup_data(
|
168
|
+
app: ASGIApp, app_version: Optional[str] = None, openapi_url: Optional[str] = None
|
169
|
+
) -> Dict[str, Any]:
|
170
|
+
data: Dict[str, Any] = {}
|
158
171
|
if openapi_url and (openapi := _get_openapi(app, openapi_url)):
|
159
|
-
|
172
|
+
data["openapi"] = openapi
|
160
173
|
if endpoints := _get_endpoint_info(app):
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
return
|
174
|
+
data["paths"] = [{"path": endpoint.path, "method": endpoint.http_method} for endpoint in endpoints]
|
175
|
+
data["versions"] = get_versions("fastapi", "starlette", app_version=app_version)
|
176
|
+
data["client"] = "python:starlette"
|
177
|
+
return data
|
165
178
|
|
166
179
|
|
167
180
|
def _get_openapi(app: ASGIApp, openapi_url: str) -> Optional[str]:
|
@@ -0,0 +1,19 @@
|
|
1
|
+
apitally/__init__.py,sha256=raMu9XA9JEjvdoTmFqcOw7qhJX24rYDP7XmS59TAO-Q,23
|
2
|
+
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
+
apitally/client/asyncio.py,sha256=7Q1LRENXdN69pmRHc3IXdxWVKoWrfYoJiImj4ufyLcw,4692
|
4
|
+
apitally/client/base.py,sha256=IbAyC1VCONGA1B_Wj0jM82pkAn_cnVKe_iqR5xy3Zp0,15299
|
5
|
+
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
+
apitally/client/threading.py,sha256=tgADSLbqQFnf5JqFmDT3ul3Jch00jZtU2MUJmOP22A4,5085
|
7
|
+
apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
|
8
|
+
apitally/django.py,sha256=J4dLh3vYFFm3bKgoQesGmghRmPDV5sOU2DpC6OxvW-Y,13612
|
9
|
+
apitally/django_ninja.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
10
|
+
apitally/django_rest_framework.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
11
|
+
apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
|
12
|
+
apitally/flask.py,sha256=KZxWN1xeXUazYYluu3aoKkZQ_aRljHmtjZi1AxvzpGw,6402
|
13
|
+
apitally/litestar.py,sha256=sQcrHw-JV9AlpnXlrczmaDe0k6tD9PYQsc8nyQul8Ko,8802
|
14
|
+
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
+
apitally/starlette.py,sha256=ib73Xe8GUMDwR4uREZsJjtmJtgk7uNEiINr9ex3Atm0,8612
|
16
|
+
apitally-0.11.0.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
17
|
+
apitally-0.11.0.dist-info/METADATA,sha256=822XloDi47QyB8-wm7sWbNwMU9BQ7n7BVuszSwEXDvc,6834
|
18
|
+
apitally-0.11.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
19
|
+
apitally-0.11.0.dist-info/RECORD,,
|
apitally-0.10.1.dist-info/RECORD
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
apitally/__init__.py,sha256=v7Gyp89umFzDtY45tTjCdXqZnQ2RN01AibdYNxEvxYo,23
|
2
|
-
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
|
-
apitally/client/asyncio.py,sha256=nvW3LcwIJsfE4h-nHiHJLSdnluxdZGu9AyH6l6MJH24,4556
|
4
|
-
apitally/client/base.py,sha256=VWtHTkA6UT5aV37i_CXUvcfmYrabjHGBhzbDU6EeH1A,12785
|
5
|
-
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
6
|
-
apitally/client/threading.py,sha256=aEFDXDr5SI2g0Zv1GNcP9PhToeZ5Tp2VhPtPwbPGAIs,4957
|
7
|
-
apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
|
8
|
-
apitally/django.py,sha256=ZsCKTUq4V3nCwIcrO8_3mPQFuPRiriqRSxQ8IPMRoOQ,12922
|
9
|
-
apitally/django_ninja.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
10
|
-
apitally/django_rest_framework.py,sha256=iMvZd7j04nbOLpJgYxs7tpbsyXlZuhmHjcswXMvyUlU,82
|
11
|
-
apitally/fastapi.py,sha256=Q3n2bVREKQ_V_2yCQ48ngPtr-NJxDskpT_l20xhSbpM,85
|
12
|
-
apitally/flask.py,sha256=iD2mbFqrWoEFFmDNXUtR32OYwlHob4I3tJT90-kwcnw,5691
|
13
|
-
apitally/litestar.py,sha256=fgpdIlOpXS7TYFM02GUXk78H8WuJgDWMePPsQf-Pw1I,8092
|
14
|
-
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
15
|
-
apitally/starlette.py,sha256=e0PoLTrihHioLGDF1TThjlsiyYtusa6Or6GKCX7Y9NQ,7881
|
16
|
-
apitally-0.10.1.dist-info/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
17
|
-
apitally-0.10.1.dist-info/METADATA,sha256=m6jKQm1yML6RCaURdrSzJsn76em3JYScavaPDyCvxQs,6834
|
18
|
-
apitally-0.10.1.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
19
|
-
apitally-0.10.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|