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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.10.1"
1
+ __version__ = "0.11.0"
@@ -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._requests_data_queue: asyncio.Queue[Tuple[float, Dict[str, Any]]] = asyncio.Queue()
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.send_requests_data(client)]
48
- if not self._app_info_sent and not first_iteration:
49
- tasks.append(self.send_app_info(client))
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 as e: # pragma: no cover
54
- logger.exception(e)
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 requests data before exiting
63
+ # Send any remaining data before exiting
64
64
  async with self.get_http_client() as client:
65
- await self.send_requests_data(client)
65
+ await self.send_sync_data(client)
66
66
 
67
- def set_app_info(self, app_info: Dict[str, Any]) -> None:
68
- self._app_info_sent = False
69
- self._app_info_payload = self.get_info_payload(app_info)
70
- asyncio.create_task(self._set_app_info_task())
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 _set_app_info_task(self) -> None:
72
+ async def _set_startup_data_task(self) -> None:
73
73
  async with self.get_http_client() as client:
74
- await self.send_app_info(client)
74
+ await self.send_startup_data(client)
75
75
 
76
- async def send_app_info(self, client: httpx.AsyncClient) -> None:
77
- if self._app_info_payload is not None:
78
- await self._send_app_info(client, self._app_info_payload)
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 send_requests_data(self, client: httpx.AsyncClient) -> None:
81
- payload = self.get_requests_payload()
82
- self._requests_data_queue.put_nowait((time.time(), payload))
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._requests_data_queue.empty():
86
- payload_time, payload = self._requests_data_queue.get_nowait()
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() - payload_time) <= MAX_QUEUE_TIME:
89
- payload["time_offset"] = time_offset
90
- await self._send_requests_data(client, payload)
91
- self._requests_data_queue.task_done()
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((payload_time, payload))
93
+ failed_items.append((timestamp, data))
94
94
  for item in failed_items:
95
- self._requests_data_queue.put_nowait(item)
95
+ self._sync_data_queue.put_nowait(item)
96
96
 
97
97
  @retry(raise_on_giveup=False)
98
- async def _send_app_info(self, client: httpx.AsyncClient, payload: Dict[str, Any]) -> None:
99
- logger.debug("Sending app info")
100
- response = await client.post(url="/info", json=payload, timeout=REQUEST_TIMEOUT)
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._app_info_sent = True
103
- self._app_info_payload = None
102
+ self._startup_data_sent = True
103
+ self._startup_data = None
104
104
 
105
105
  @retry()
106
- async def _send_requests_data(self, client: httpx.AsyncClient, payload: Dict[str, Any]) -> None:
107
- logger.debug("Sending requests data")
108
- response = await client.post(url="/requests", json=payload)
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(f"Invalid Apitally client ID {self.client_id}")
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 = "v1"
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._app_info_payload: Optional[Dict[str, Any]] = None
64
- self._app_info_sent = False
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 get_info_payload(self, app_info: Dict[str, Any]) -> Dict[str, Any]:
84
- payload = {
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
- payload.update(app_info)
89
- return payload
90
-
91
- def get_requests_payload(self) -> Dict[str, Any]:
92
- requests = self.request_counter.get_and_reset_requests()
93
- validation_errors = self.validation_error_counter.get_and_reset_validation_errors()
94
- server_errors = self.server_error_counter.get_and_reset_server_errors()
95
- return {
96
- "instance_uuid": self.instance_uuid,
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
@@ -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._requests_data_queue: queue.Queue[Tuple[float, Dict[str, Any]]] = queue.Queue()
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._app_info_sent and last_sync_time > 0: # not on first sync
67
- self.send_app_info(session)
68
- self.send_requests_data(session)
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 as e: # pragma: no cover
72
- logger.exception(e)
71
+ except Exception: # pragma: no cover
72
+ logger.exception("An error occurred during sync with Apitally hub")
73
73
  finally:
74
- # Send any remaining requests data before exiting
74
+ # Send any remaining data before exiting
75
75
  with requests.Session() as session:
76
- self.send_requests_data(session)
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 set_app_info(self, app_info: Dict[str, Any]) -> None:
85
- self._app_info_sent = False
86
- self._app_info_payload = self.get_info_payload(app_info)
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.send_app_info(session)
88
+ self.send_startup_data(session)
89
89
 
90
- def send_app_info(self, session: requests.Session) -> None:
91
- if self._app_info_payload is not None:
92
- self._send_app_info(session, self._app_info_payload)
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 send_requests_data(self, session: requests.Session) -> None:
95
- payload = self.get_requests_payload()
96
- self._requests_data_queue.put_nowait((time.time(), payload))
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._requests_data_queue.empty():
100
- payload_time, payload = self._requests_data_queue.get_nowait()
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() - payload_time) <= MAX_QUEUE_TIME:
103
- payload["time_offset"] = time_offset
104
- self._send_requests_data(session, payload)
105
- self._requests_data_queue.task_done()
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((payload_time, payload))
107
+ failed_items.append((timestamp, data))
108
108
  for item in failed_items:
109
- self._requests_data_queue.put_nowait(item)
109
+ self._sync_data_queue.put_nowait(item)
110
110
 
111
111
  @retry(raise_on_giveup=False)
112
- def _send_app_info(self, session: requests.Session, payload: Dict[str, Any]) -> None:
113
- logger.debug("Sending app info")
114
- response = session.post(url=f"{self.hub_url}/info", json=payload, timeout=REQUEST_TIMEOUT)
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._app_info_sent = True
117
- self._app_info_payload = None
116
+ self._startup_data_sent = True
117
+ self._startup_data = None
118
118
 
119
119
  @retry()
120
- def _send_requests_data(self, session: requests.Session, payload: Dict[str, Any]) -> None:
121
- logger.debug("Sending requests data")
122
- response = session.post(url=f"{self.hub_url}/requests", json=payload, timeout=REQUEST_TIMEOUT)
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(f"Invalid Apitally client ID {self.client_id}")
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], Optional[str]]]
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.set_app_info(
65
- app_info=_get_app_info(
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
- consumer = self.get_consumer(request)
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=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=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=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[str]:
166
- try:
167
- if hasattr(request, "apitally_consumer"):
168
- return str(request.apitally_consumer)
169
- if hasattr(request, "consumer_identifier"): # Keeping this for legacy support
170
- return str(request.consumer_identifier)
171
- if self.config is not None and self.config.identify_consumer_callback is not None:
172
- consumer = self.config.identify_consumer_callback(request)
173
- if consumer is not None:
174
- return str(consumer)
175
- except Exception: # pragma: no cover
176
- logger.exception("Failed to get consumer identifier for request")
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 _get_app_info(app_version: Optional[str], urlconfs: List[Optional[str]]) -> Dict[str, Any]:
181
- app_info: Dict[str, Any] = {}
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
- app_info["paths"] = _get_paths(urlconfs)
193
+ data["paths"] = _get_paths(urlconfs)
184
194
  except Exception: # pragma: no cover
185
- app_info["paths"] = []
195
+ data["paths"] = []
186
196
  logger.exception("Failed to get paths")
187
197
  try:
188
- app_info["openapi"] = _get_openapi(urlconfs)
198
+ data["openapi"] = _get_openapi(urlconfs)
189
199
  except Exception: # pragma: no cover
190
200
  logger.exception("Failed to get OpenAPI schema")
191
- app_info["versions"] = get_versions("django", "djangorestframework", "django-ninja", app_version=app_version)
192
- app_info["client"] = "python:django"
193
- return app_info
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
@@ -1,4 +1,4 @@
1
- from apitally.django import ApitallyMiddleware
1
+ from apitally.django import ApitallyConsumer, ApitallyMiddleware
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
@@ -1,4 +1,4 @@
1
- from apitally.django import ApitallyMiddleware
1
+ from apitally.django import ApitallyConsumer, ApitallyMiddleware
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
apitally/fastapi.py CHANGED
@@ -1,4 +1,4 @@
1
- from apitally.starlette import ApitallyMiddleware
1
+ from apitally.starlette import ApitallyConsumer, ApitallyMiddleware
2
2
 
3
3
 
4
- __all__ = ["ApitallyMiddleware"]
4
+ __all__ = ["ApitallyMiddleware", "ApitallyConsumer"]
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.delayed_set_app_info(app_version, openapi_url)
43
+ self.delayed_set_startup_data(app_version, openapi_url)
42
44
 
43
- def delayed_set_app_info(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
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(1.0, self._delayed_set_app_info, kwargs={"app_version": app_version, "openapi_url": openapi_url})
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 _delayed_set_app_info(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
49
- app_info = _get_app_info(self.app, app_version, openapi_url)
50
- self.client.set_app_info(app_info)
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=self.get_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=self.get_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[str]:
118
- if "apitally_consumer" in g:
119
- return str(g.apitally_consumer)
120
- if "consumer_identifier" in g: # Keeping this for legacy support
121
- return str(g.consumer_identifier)
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 _get_app_info(app: Flask, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> Dict[str, Any]:
126
- app_info: Dict[str, Any] = {}
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
- app_info["openapi"] = openapi
145
+ data["openapi"] = openapi
129
146
  if paths := _get_paths(app.url_map):
130
- app_info["paths"] = paths
131
- app_info["versions"] = get_versions("flask", app_version=app_version)
132
- app_info["client"] = "python:flask"
133
- return app_info
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], Optional[str]]] = None,
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
- app_info = {
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.set_app_info(app_info)
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=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=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=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[str]:
171
- if hasattr(request.state, "apitally_consumer"):
172
- return str(request.state.apitally_consumer)
173
- if hasattr(request.state, "consumer_identifier"): # Keeping this for legacy support
174
- return str(request.state.consumer_identifier)
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
- if consumer is not None:
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], Optional[str]]] = None,
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.delayed_set_app_info(app_version, openapi_url)
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 delayed_set_app_info(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
50
- asyncio.create_task(self._delayed_set_app_info(app_version, openapi_url))
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 _delayed_set_app_info(self, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> None:
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
- app_info = _get_app_info(self.app, app_version, openapi_url)
55
- self.client.set_app_info(app_info)
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=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=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=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[str]:
145
- if hasattr(request.state, "apitally_consumer"):
146
- return str(request.state.apitally_consumer)
147
- if hasattr(request.state, "consumer_identifier"): # Keeping this for legacy support
148
- return str(request.state.consumer_identifier)
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
- if consumer is not None:
152
- return str(consumer)
163
+ return ApitallyConsumer.from_string_or_object(consumer)
153
164
  return None
154
165
 
155
166
 
156
- def _get_app_info(app: ASGIApp, app_version: Optional[str] = None, openapi_url: Optional[str] = None) -> Dict[str, Any]:
157
- app_info: Dict[str, Any] = {}
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
- app_info["openapi"] = openapi
172
+ data["openapi"] = openapi
160
173
  if endpoints := _get_endpoint_info(app):
161
- app_info["paths"] = [{"path": endpoint.path, "method": endpoint.http_method} for endpoint in endpoints]
162
- app_info["versions"] = get_versions("fastapi", "starlette", app_version=app_version)
163
- app_info["client"] = "python:starlette"
164
- return app_info
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]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: apitally
3
- Version: 0.10.1
3
+ Version: 0.11.0
4
4
  Summary: Simple API monitoring & analytics for REST APIs built with FastAPI, Flask, Django, Starlette and Litestar.
5
5
  Home-page: https://apitally.io
6
6
  License: MIT
@@ -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,,
@@ -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,,