apitally 0.13.0__py3-none-any.whl → 0.14.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/client/{asyncio.py → client_asyncio.py} +55 -16
- apitally/client/client_base.py +97 -0
- apitally/client/{threading.py → client_threading.py} +51 -10
- apitally/client/consumers.py +66 -0
- apitally/client/request_logging.py +340 -0
- apitally/client/requests.py +86 -0
- apitally/client/server_errors.py +126 -0
- apitally/client/validation_errors.py +58 -0
- apitally/common.py +10 -1
- apitally/django.py +112 -46
- apitally/django_ninja.py +2 -2
- apitally/django_rest_framework.py +2 -2
- apitally/fastapi.py +2 -2
- apitally/flask.py +100 -26
- apitally/litestar.py +122 -54
- apitally/starlette.py +90 -29
- {apitally-0.13.0.dist-info → apitally-0.14.0.dist-info}/METADATA +1 -2
- apitally-0.14.0.dist-info/RECORD +24 -0
- {apitally-0.13.0.dist-info → apitally-0.14.0.dist-info}/WHEEL +1 -1
- apitally/client/base.py +0 -404
- apitally-0.13.0.dist-info/RECORD +0 -19
- {apitally-0.13.0.dist-info → apitally-0.14.0.dist-info}/licenses/LICENSE +0 -0
apitally/client/base.py
DELETED
@@ -1,404 +0,0 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import asyncio
|
4
|
-
import contextlib
|
5
|
-
import os
|
6
|
-
import re
|
7
|
-
import sys
|
8
|
-
import threading
|
9
|
-
import time
|
10
|
-
import traceback
|
11
|
-
from abc import ABC
|
12
|
-
from collections import Counter
|
13
|
-
from dataclasses import dataclass
|
14
|
-
from math import floor
|
15
|
-
from typing import Any, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union, cast
|
16
|
-
from uuid import UUID, uuid4
|
17
|
-
|
18
|
-
from apitally.client.logging import get_logger
|
19
|
-
|
20
|
-
|
21
|
-
logger = get_logger(__name__)
|
22
|
-
|
23
|
-
HUB_BASE_URL = os.getenv("APITALLY_HUB_BASE_URL") or "https://hub.apitally.io"
|
24
|
-
HUB_VERSION = "v2"
|
25
|
-
REQUEST_TIMEOUT = 10
|
26
|
-
MAX_QUEUE_TIME = 3600
|
27
|
-
SYNC_INTERVAL = 60
|
28
|
-
INITIAL_SYNC_INTERVAL = 10
|
29
|
-
INITIAL_SYNC_INTERVAL_DURATION = 3600
|
30
|
-
MAX_EXCEPTION_MSG_LENGTH = 2048
|
31
|
-
MAX_EXCEPTION_TRACEBACK_LENGTH = 65536
|
32
|
-
|
33
|
-
TApitallyClient = TypeVar("TApitallyClient", bound="ApitallyClientBase")
|
34
|
-
|
35
|
-
|
36
|
-
class ApitallyClientBase(ABC):
|
37
|
-
_instance: Optional[ApitallyClientBase] = None
|
38
|
-
_lock = threading.Lock()
|
39
|
-
|
40
|
-
def __new__(cls: Type[TApitallyClient], *args, **kwargs) -> TApitallyClient:
|
41
|
-
if cls._instance is None:
|
42
|
-
with cls._lock:
|
43
|
-
if cls._instance is None:
|
44
|
-
cls._instance = super().__new__(cls)
|
45
|
-
return cast(TApitallyClient, cls._instance)
|
46
|
-
|
47
|
-
def __init__(self, client_id: str, env: str) -> None:
|
48
|
-
if hasattr(self, "client_id"):
|
49
|
-
raise RuntimeError("Apitally client is already initialized") # pragma: no cover
|
50
|
-
try:
|
51
|
-
UUID(client_id)
|
52
|
-
except ValueError:
|
53
|
-
raise ValueError(f"invalid client_id '{client_id}' (expecting hexadecimal UUID format)")
|
54
|
-
if re.match(r"^[\w-]{1,32}$", env) is None:
|
55
|
-
raise ValueError(f"invalid env '{env}' (expecting 1-32 alphanumeric lowercase characters and hyphens only)")
|
56
|
-
|
57
|
-
self.client_id = client_id
|
58
|
-
self.env = env
|
59
|
-
self.instance_uuid = str(uuid4())
|
60
|
-
self.request_counter = RequestCounter()
|
61
|
-
self.validation_error_counter = ValidationErrorCounter()
|
62
|
-
self.server_error_counter = ServerErrorCounter()
|
63
|
-
self.consumer_registry = ConsumerRegistry()
|
64
|
-
|
65
|
-
self._startup_data: Optional[Dict[str, Any]] = None
|
66
|
-
self._startup_data_sent = False
|
67
|
-
self._started_at = time.time()
|
68
|
-
|
69
|
-
@classmethod
|
70
|
-
def get_instance(cls: Type[TApitallyClient]) -> TApitallyClient:
|
71
|
-
if cls._instance is None:
|
72
|
-
raise RuntimeError("Apitally client not initialized") # pragma: no cover
|
73
|
-
return cast(TApitallyClient, cls._instance)
|
74
|
-
|
75
|
-
@property
|
76
|
-
def sync_interval(self) -> float:
|
77
|
-
return (
|
78
|
-
SYNC_INTERVAL if time.time() - self._started_at > INITIAL_SYNC_INTERVAL_DURATION else INITIAL_SYNC_INTERVAL
|
79
|
-
)
|
80
|
-
|
81
|
-
@property
|
82
|
-
def hub_url(self) -> str:
|
83
|
-
return f"{HUB_BASE_URL}/{HUB_VERSION}/{self.client_id}/{self.env}"
|
84
|
-
|
85
|
-
def add_uuids_to_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
86
|
-
data_with_uuids = {
|
87
|
-
"instance_uuid": self.instance_uuid,
|
88
|
-
"message_uuid": str(uuid4()),
|
89
|
-
}
|
90
|
-
data_with_uuids.update(data)
|
91
|
-
return data_with_uuids
|
92
|
-
|
93
|
-
def get_sync_data(self) -> Dict[str, Any]:
|
94
|
-
data = {
|
95
|
-
"requests": self.request_counter.get_and_reset_requests(),
|
96
|
-
"validation_errors": self.validation_error_counter.get_and_reset_validation_errors(),
|
97
|
-
"server_errors": self.server_error_counter.get_and_reset_server_errors(),
|
98
|
-
"consumers": self.consumer_registry.get_and_reset_updated_consumers(),
|
99
|
-
}
|
100
|
-
return self.add_uuids_to_data(data)
|
101
|
-
|
102
|
-
|
103
|
-
@dataclass(frozen=True)
|
104
|
-
class RequestInfo:
|
105
|
-
consumer: Optional[str]
|
106
|
-
method: str
|
107
|
-
path: str
|
108
|
-
status_code: int
|
109
|
-
|
110
|
-
|
111
|
-
class RequestCounter:
|
112
|
-
def __init__(self) -> None:
|
113
|
-
self.request_counts: Counter[RequestInfo] = Counter()
|
114
|
-
self.request_size_sums: Counter[RequestInfo] = Counter()
|
115
|
-
self.response_size_sums: Counter[RequestInfo] = Counter()
|
116
|
-
self.response_times: Dict[RequestInfo, Counter[int]] = {}
|
117
|
-
self.request_sizes: Dict[RequestInfo, Counter[int]] = {}
|
118
|
-
self.response_sizes: Dict[RequestInfo, Counter[int]] = {}
|
119
|
-
self._lock = threading.Lock()
|
120
|
-
|
121
|
-
def add_request(
|
122
|
-
self,
|
123
|
-
consumer: Optional[str],
|
124
|
-
method: str,
|
125
|
-
path: str,
|
126
|
-
status_code: int,
|
127
|
-
response_time: float,
|
128
|
-
request_size: str | int | None = None,
|
129
|
-
response_size: str | int | None = None,
|
130
|
-
) -> None:
|
131
|
-
request_info = RequestInfo(
|
132
|
-
consumer=consumer,
|
133
|
-
method=method.upper(),
|
134
|
-
path=path,
|
135
|
-
status_code=status_code,
|
136
|
-
)
|
137
|
-
response_time_ms_bin = int(floor(response_time / 0.01) * 10) # In ms, rounded down to nearest 10ms
|
138
|
-
with self._lock:
|
139
|
-
self.request_counts[request_info] += 1
|
140
|
-
self.response_times.setdefault(request_info, Counter())[response_time_ms_bin] += 1
|
141
|
-
if request_size is not None:
|
142
|
-
with contextlib.suppress(ValueError):
|
143
|
-
request_size = int(request_size)
|
144
|
-
request_size_kb_bin = request_size // 1000 # In KB, rounded down to nearest 1KB
|
145
|
-
self.request_size_sums[request_info] += request_size
|
146
|
-
self.request_sizes.setdefault(request_info, Counter())[request_size_kb_bin] += 1
|
147
|
-
if response_size is not None:
|
148
|
-
with contextlib.suppress(ValueError):
|
149
|
-
response_size = int(response_size)
|
150
|
-
response_size_kb_bin = response_size // 1000 # In KB, rounded down to nearest 1KB
|
151
|
-
self.response_size_sums[request_info] += response_size
|
152
|
-
self.response_sizes.setdefault(request_info, Counter())[response_size_kb_bin] += 1
|
153
|
-
|
154
|
-
def get_and_reset_requests(self) -> List[Dict[str, Any]]:
|
155
|
-
data: List[Dict[str, Any]] = []
|
156
|
-
with self._lock:
|
157
|
-
for request_info, count in self.request_counts.items():
|
158
|
-
data.append(
|
159
|
-
{
|
160
|
-
"consumer": request_info.consumer,
|
161
|
-
"method": request_info.method,
|
162
|
-
"path": request_info.path,
|
163
|
-
"status_code": request_info.status_code,
|
164
|
-
"request_count": count,
|
165
|
-
"request_size_sum": self.request_size_sums.get(request_info, 0),
|
166
|
-
"response_size_sum": self.response_size_sums.get(request_info, 0),
|
167
|
-
"response_times": self.response_times.get(request_info) or Counter(),
|
168
|
-
"request_sizes": self.request_sizes.get(request_info) or Counter(),
|
169
|
-
"response_sizes": self.response_sizes.get(request_info) or Counter(),
|
170
|
-
}
|
171
|
-
)
|
172
|
-
self.request_counts.clear()
|
173
|
-
self.request_size_sums.clear()
|
174
|
-
self.response_size_sums.clear()
|
175
|
-
self.response_times.clear()
|
176
|
-
self.request_sizes.clear()
|
177
|
-
self.response_sizes.clear()
|
178
|
-
return data
|
179
|
-
|
180
|
-
|
181
|
-
@dataclass(frozen=True)
|
182
|
-
class ValidationError:
|
183
|
-
consumer: Optional[str]
|
184
|
-
method: str
|
185
|
-
path: str
|
186
|
-
loc: Tuple[str, ...]
|
187
|
-
msg: str
|
188
|
-
type: str
|
189
|
-
|
190
|
-
|
191
|
-
class ValidationErrorCounter:
|
192
|
-
def __init__(self) -> None:
|
193
|
-
self.error_counts: Counter[ValidationError] = Counter()
|
194
|
-
self._lock = threading.Lock()
|
195
|
-
|
196
|
-
def add_validation_errors(
|
197
|
-
self, consumer: Optional[str], method: str, path: str, detail: List[Dict[str, Any]]
|
198
|
-
) -> None:
|
199
|
-
with self._lock:
|
200
|
-
for error in detail:
|
201
|
-
try:
|
202
|
-
validation_error = ValidationError(
|
203
|
-
consumer=consumer,
|
204
|
-
method=method.upper(),
|
205
|
-
path=path,
|
206
|
-
loc=tuple(str(loc) for loc in error["loc"]),
|
207
|
-
msg=error["msg"],
|
208
|
-
type=error["type"],
|
209
|
-
)
|
210
|
-
self.error_counts[validation_error] += 1
|
211
|
-
except (KeyError, TypeError): # pragma: no cover
|
212
|
-
pass
|
213
|
-
|
214
|
-
def get_and_reset_validation_errors(self) -> List[Dict[str, Any]]:
|
215
|
-
data: List[Dict[str, Any]] = []
|
216
|
-
with self._lock:
|
217
|
-
for validation_error, count in self.error_counts.items():
|
218
|
-
data.append(
|
219
|
-
{
|
220
|
-
"consumer": validation_error.consumer,
|
221
|
-
"method": validation_error.method,
|
222
|
-
"path": validation_error.path,
|
223
|
-
"loc": validation_error.loc,
|
224
|
-
"msg": validation_error.msg,
|
225
|
-
"type": validation_error.type,
|
226
|
-
"error_count": count,
|
227
|
-
}
|
228
|
-
)
|
229
|
-
self.error_counts.clear()
|
230
|
-
return data
|
231
|
-
|
232
|
-
|
233
|
-
@dataclass(frozen=True)
|
234
|
-
class ServerError:
|
235
|
-
consumer: Optional[str]
|
236
|
-
method: str
|
237
|
-
path: str
|
238
|
-
type: str
|
239
|
-
msg: str
|
240
|
-
traceback: str
|
241
|
-
|
242
|
-
|
243
|
-
class ServerErrorCounter:
|
244
|
-
def __init__(self) -> None:
|
245
|
-
self.error_counts: Counter[ServerError] = Counter()
|
246
|
-
self.sentry_event_ids: Dict[ServerError, str] = {}
|
247
|
-
self._lock = threading.Lock()
|
248
|
-
self._tasks: Set[asyncio.Task] = set()
|
249
|
-
|
250
|
-
def add_server_error(self, consumer: Optional[str], method: str, path: str, exception: BaseException) -> None:
|
251
|
-
if not isinstance(exception, BaseException):
|
252
|
-
return # pragma: no cover
|
253
|
-
exception_type = type(exception)
|
254
|
-
with self._lock:
|
255
|
-
server_error = ServerError(
|
256
|
-
consumer=consumer,
|
257
|
-
method=method.upper(),
|
258
|
-
path=path,
|
259
|
-
type=f"{exception_type.__module__}.{exception_type.__qualname__}",
|
260
|
-
msg=self._get_truncated_exception_msg(exception),
|
261
|
-
traceback=self._get_truncated_exception_traceback(exception),
|
262
|
-
)
|
263
|
-
self.error_counts[server_error] += 1
|
264
|
-
self.capture_sentry_event_id(server_error)
|
265
|
-
|
266
|
-
def capture_sentry_event_id(self, server_error: ServerError) -> None:
|
267
|
-
try:
|
268
|
-
from sentry_sdk.hub import Hub
|
269
|
-
from sentry_sdk.scope import Scope
|
270
|
-
except ImportError:
|
271
|
-
return # pragma: no cover
|
272
|
-
if not hasattr(Scope, "get_isolation_scope") or not hasattr(Scope, "_last_event_id"):
|
273
|
-
# sentry-sdk < 2.2.0 is not supported
|
274
|
-
return # pragma: no cover
|
275
|
-
if Hub.current.client is None:
|
276
|
-
return # sentry-sdk not initialized
|
277
|
-
|
278
|
-
scope = Scope.get_isolation_scope()
|
279
|
-
if event_id := scope._last_event_id:
|
280
|
-
self.sentry_event_ids[server_error] = event_id
|
281
|
-
return
|
282
|
-
|
283
|
-
async def _wait_for_sentry_event_id(scope: Scope) -> None:
|
284
|
-
i = 0
|
285
|
-
while not (event_id := scope._last_event_id) and i < 100:
|
286
|
-
i += 1
|
287
|
-
await asyncio.sleep(0.001)
|
288
|
-
if event_id:
|
289
|
-
self.sentry_event_ids[server_error] = event_id
|
290
|
-
|
291
|
-
with contextlib.suppress(RuntimeError): # ignore no running loop
|
292
|
-
loop = asyncio.get_running_loop()
|
293
|
-
task = loop.create_task(_wait_for_sentry_event_id(scope))
|
294
|
-
self._tasks.add(task)
|
295
|
-
task.add_done_callback(self._tasks.discard)
|
296
|
-
|
297
|
-
def get_and_reset_server_errors(self) -> List[Dict[str, Any]]:
|
298
|
-
data: List[Dict[str, Any]] = []
|
299
|
-
with self._lock:
|
300
|
-
for server_error, count in self.error_counts.items():
|
301
|
-
data.append(
|
302
|
-
{
|
303
|
-
"consumer": server_error.consumer,
|
304
|
-
"method": server_error.method,
|
305
|
-
"path": server_error.path,
|
306
|
-
"type": server_error.type,
|
307
|
-
"msg": server_error.msg,
|
308
|
-
"traceback": server_error.traceback,
|
309
|
-
"sentry_event_id": self.sentry_event_ids.get(server_error),
|
310
|
-
"error_count": count,
|
311
|
-
}
|
312
|
-
)
|
313
|
-
self.error_counts.clear()
|
314
|
-
self.sentry_event_ids.clear()
|
315
|
-
return data
|
316
|
-
|
317
|
-
@staticmethod
|
318
|
-
def _get_truncated_exception_msg(exception: BaseException) -> str:
|
319
|
-
msg = str(exception).strip()
|
320
|
-
if len(msg) <= MAX_EXCEPTION_MSG_LENGTH:
|
321
|
-
return msg
|
322
|
-
suffix = "... (truncated)"
|
323
|
-
cutoff = MAX_EXCEPTION_MSG_LENGTH - len(suffix)
|
324
|
-
return msg[:cutoff] + suffix
|
325
|
-
|
326
|
-
@staticmethod
|
327
|
-
def _get_truncated_exception_traceback(exception: BaseException) -> str:
|
328
|
-
prefix = "... (truncated) ...\n"
|
329
|
-
cutoff = MAX_EXCEPTION_TRACEBACK_LENGTH - len(prefix)
|
330
|
-
lines = []
|
331
|
-
length = 0
|
332
|
-
if sys.version_info >= (3, 10):
|
333
|
-
traceback_lines = traceback.format_exception(exception)
|
334
|
-
else:
|
335
|
-
traceback_lines = traceback.format_exception(type(exception), exception, exception.__traceback__)
|
336
|
-
for line in traceback_lines[::-1]:
|
337
|
-
if length + len(line) > cutoff:
|
338
|
-
lines.append(prefix)
|
339
|
-
break
|
340
|
-
lines.append(line)
|
341
|
-
length += len(line)
|
342
|
-
return "".join(lines[::-1]).strip()
|
343
|
-
|
344
|
-
|
345
|
-
class Consumer:
|
346
|
-
def __init__(self, identifier: str, name: Optional[str] = None, group: Optional[str] = None) -> None:
|
347
|
-
self.identifier = str(identifier).strip()[:128]
|
348
|
-
self.name = str(name).strip()[:64] if name else None
|
349
|
-
self.group = str(group).strip()[:64] if group else None
|
350
|
-
|
351
|
-
@classmethod
|
352
|
-
def from_string_or_object(cls, consumer: Optional[Union[str, Consumer]]) -> Optional[Consumer]:
|
353
|
-
if not consumer:
|
354
|
-
return None
|
355
|
-
if isinstance(consumer, Consumer):
|
356
|
-
return consumer
|
357
|
-
consumer = str(consumer).strip()
|
358
|
-
if not consumer:
|
359
|
-
return None
|
360
|
-
return cls(identifier=consumer)
|
361
|
-
|
362
|
-
def update(self, name: str | None = None, group: str | None = None) -> bool:
|
363
|
-
name = str(name).strip()[:64] if name else None
|
364
|
-
group = str(group).strip()[:64] if group else None
|
365
|
-
updated = False
|
366
|
-
if name and name != self.name:
|
367
|
-
self.name = name
|
368
|
-
updated = True
|
369
|
-
if group and group != self.group:
|
370
|
-
self.group = group
|
371
|
-
updated = True
|
372
|
-
return updated
|
373
|
-
|
374
|
-
|
375
|
-
class ConsumerRegistry:
|
376
|
-
def __init__(self) -> None:
|
377
|
-
self.consumers: Dict[str, Consumer] = {}
|
378
|
-
self.updated: Set[str] = set()
|
379
|
-
self._lock = threading.Lock()
|
380
|
-
|
381
|
-
def add_or_update_consumer(self, consumer: Optional[Consumer]) -> None:
|
382
|
-
if not consumer or (not consumer.name and not consumer.group):
|
383
|
-
return # Only register consumers with name or group set
|
384
|
-
with self._lock:
|
385
|
-
if consumer.identifier not in self.consumers:
|
386
|
-
self.consumers[consumer.identifier] = consumer
|
387
|
-
self.updated.add(consumer.identifier)
|
388
|
-
elif self.consumers[consumer.identifier].update(name=consumer.name, group=consumer.group):
|
389
|
-
self.updated.add(consumer.identifier)
|
390
|
-
|
391
|
-
def get_and_reset_updated_consumers(self) -> List[Dict[str, Any]]:
|
392
|
-
data: List[Dict[str, Any]] = []
|
393
|
-
with self._lock:
|
394
|
-
for identifier in self.updated:
|
395
|
-
if consumer := self.consumers.get(identifier):
|
396
|
-
data.append(
|
397
|
-
{
|
398
|
-
"identifier": consumer.identifier,
|
399
|
-
"name": str(consumer.name)[:64] if consumer.name else None,
|
400
|
-
"group": str(consumer.group)[:64] if consumer.group else None,
|
401
|
-
}
|
402
|
-
)
|
403
|
-
self.updated.clear()
|
404
|
-
return data
|
apitally-0.13.0.dist-info/RECORD
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
apitally/__init__.py,sha256=ShXQBVjyiSOHxoQJS2BvNG395W4KZfqMxZWBAR0MZrE,22
|
2
|
-
apitally/common.py,sha256=GbVmnXxhRvV30d7CfCQ9r0AeXj14Mv9Jm_Yd1bRWP28,1088
|
3
|
-
apitally/django.py,sha256=Zw8a971UwGKaEMPUtmlBbjufAYwMkSjRSQlss8FDY-E,13795
|
4
|
-
apitally/django_ninja.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
5
|
-
apitally/django_rest_framework.py,sha256=dqQtnz2s8YWYHCwvkK5BjokjvpZJpPNhP0vng4kFtrQ,120
|
6
|
-
apitally/fastapi.py,sha256=hEyYZsvIaA3OXZSSFdey5iqeEjfBPHgfNbyX8pLm7GI,123
|
7
|
-
apitally/flask.py,sha256=7TJIoAT91-bR_7gZkL0clDk-Whl-V21hbo4nASaDmB4,6447
|
8
|
-
apitally/litestar.py,sha256=O9bSzwJC-dN6ukRqyVNYBhUqxEpzie-bR2bFojcvvMI,9547
|
9
|
-
apitally/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
10
|
-
apitally/starlette.py,sha256=B1QTvw5nf9pdnuQda5XsCfConMN81ze8WQ0ldmiTdkc,9589
|
11
|
-
apitally/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
12
|
-
apitally/client/asyncio.py,sha256=Y5sbRLRnJCIJx9VQ2DGgQsYNKGvURV2U1y3VxHuPhgQ,4874
|
13
|
-
apitally/client/base.py,sha256=BC_KNDuhDQcjTasvztPey9879ITcuARhgCm6HSYbGTI,15663
|
14
|
-
apitally/client/logging.py,sha256=QMsKIIAFo92PNBUleeTgsrsQa7SEal-oJa1oOHUr1wI,507
|
15
|
-
apitally/client/threading.py,sha256=cASa0C9nyRp5gf5IzCDj6TE-v8t8SW4zJ38W6NdJ3Q8,5204
|
16
|
-
apitally-0.13.0.dist-info/METADATA,sha256=-MZ4Xne70XtcDIT3waW4QvrMGnLdu-Sfz-CghqyJcf4,7599
|
17
|
-
apitally-0.13.0.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
|
18
|
-
apitally-0.13.0.dist-info/licenses/LICENSE,sha256=vbLzC-4TddtXX-_AFEBKMYWRlxC_MN0g66QhPxo8PgY,1065
|
19
|
-
apitally-0.13.0.dist-info/RECORD,,
|
File without changes
|