willisapi-client 1.9.7__tar.gz → 1.9.8__tar.gz
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.
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/PKG-INFO +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/__version__.py +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/upload.py +68 -28
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/utils.py +33 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/PKG-INFO +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/LICENSE +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/MANIFEST.in +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/README.md +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/RELEASE.md +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/requirements.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/setup.cfg +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/setup.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/tests/test_diarize.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/tests/test_diarize_call_remaining.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/logging_setup.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/api.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/diarize_utils.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/willisdiarize.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/willisdiarize_call_remaining.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/exceptions.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/archive.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/language_choices.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/timer.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/willisapi_client.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/SOURCES.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/dependency_links.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/not-zip-safe +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/requires.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/top_level.txt +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Version details for willisapi_client"""
|
|
2
2
|
|
|
3
3
|
__client__ = "willisapi_client"
|
|
4
|
-
__latestVersion__ = "1.9.
|
|
4
|
+
__latestVersion__ = "1.9.8"
|
|
5
5
|
__url__ = "https://github.com/bklynhlth/willsiapi_client"
|
|
6
6
|
__short_description__ = "A Python client for willisapi"
|
|
7
7
|
__content_type__ = "text/markdown"
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/upload.py
RENAMED
|
@@ -4,6 +4,8 @@ from datetime import datetime
|
|
|
4
4
|
import requests
|
|
5
5
|
import mimetypes
|
|
6
6
|
import os
|
|
7
|
+
import time
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
9
|
|
|
8
10
|
# Register extensions that Python's default mimetypes table doesn't know about.
|
|
9
11
|
# The server signs presigned URLs with these types, so the client must match.
|
|
@@ -16,6 +18,7 @@ from willisapi_client.services.metadata.utils import (
|
|
|
16
18
|
MetadataValidation,
|
|
17
19
|
ProcessedMetadataValidation,
|
|
18
20
|
UploadUtils,
|
|
21
|
+
build_retry_session,
|
|
19
22
|
find_files_with_pattern,
|
|
20
23
|
get_last_n_directories,
|
|
21
24
|
)
|
|
@@ -27,6 +30,42 @@ from willisapi_client.services.metadata.archive import (
|
|
|
27
30
|
VALID_SCORE_TYPES = ["rater", "reviewer"]
|
|
28
31
|
|
|
29
32
|
|
|
33
|
+
def _put_file_to_s3(file_presigned: dict):
|
|
34
|
+
"""Upload a single file to its presigned S3 URL.
|
|
35
|
+
|
|
36
|
+
Returns an error string if the upload fails, otherwise None. Presigned
|
|
37
|
+
URLs from a single recording are all signed at once and share one expiry
|
|
38
|
+
window, so callers should run these concurrently to fit within it.
|
|
39
|
+
"""
|
|
40
|
+
presigned = file_presigned.get("presigned")
|
|
41
|
+
checksum = file_presigned.get("checksum")
|
|
42
|
+
recording = file_presigned.get("recording")
|
|
43
|
+
try:
|
|
44
|
+
content_type, _ = mimetypes.guess_type(recording)
|
|
45
|
+
if not content_type:
|
|
46
|
+
content_type = "text/csv"
|
|
47
|
+
with open(recording, "rb") as f:
|
|
48
|
+
session = build_retry_session()
|
|
49
|
+
response = session.put(
|
|
50
|
+
presigned,
|
|
51
|
+
data=f,
|
|
52
|
+
headers={
|
|
53
|
+
"x-amz-checksum-sha256": checksum,
|
|
54
|
+
"x-amz-sdk-checksum-algorithm": "SHA256",
|
|
55
|
+
"Content-Type": content_type,
|
|
56
|
+
},
|
|
57
|
+
timeout=(10, 300),
|
|
58
|
+
)
|
|
59
|
+
if response.status_code != 200:
|
|
60
|
+
return (
|
|
61
|
+
f"S3 upload failed with status code {response.status_code} "
|
|
62
|
+
f"for file {recording}: {response.text}"
|
|
63
|
+
)
|
|
64
|
+
except Exception as ex:
|
|
65
|
+
return str(ex)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
30
69
|
@measure
|
|
31
70
|
def upload(api_key: str, csv_path: str, **kwargs):
|
|
32
71
|
|
|
@@ -75,7 +114,8 @@ def upload(api_key: str, csv_path: str, **kwargs):
|
|
|
75
114
|
if not content_type:
|
|
76
115
|
content_type = "application/octet-stream"
|
|
77
116
|
with open(row.file_path, "rb") as f:
|
|
78
|
-
|
|
117
|
+
session = build_retry_session()
|
|
118
|
+
response = session.put(
|
|
79
119
|
presigned,
|
|
80
120
|
data=f,
|
|
81
121
|
headers={
|
|
@@ -85,13 +125,14 @@ def upload(api_key: str, csv_path: str, **kwargs):
|
|
|
85
125
|
"x-amz-sdk-checksum-algorithm": "SHA256",
|
|
86
126
|
"Content-Type": content_type,
|
|
87
127
|
},
|
|
128
|
+
timeout=(10, 300),
|
|
88
129
|
)
|
|
89
130
|
if response.status_code == 200:
|
|
90
131
|
result_row["upload_status"] = "Success"
|
|
91
132
|
else:
|
|
92
133
|
result_row["upload_status"] = "Failed"
|
|
93
134
|
result_row["error"] = (
|
|
94
|
-
f"S3 upload failed with status code {response.status_code}"
|
|
135
|
+
f"S3 upload failed with status code {response.status_code}: {response.text}"
|
|
95
136
|
)
|
|
96
137
|
except Exception as ex:
|
|
97
138
|
result_row["upload_status"] = "Failed"
|
|
@@ -161,6 +202,12 @@ def processed_upload(api_key: str, csv_path: str, output_path: str, **kwargs):
|
|
|
161
202
|
)
|
|
162
203
|
|
|
163
204
|
results = []
|
|
205
|
+
# Throttle S3 uploads: pause 3s each time the running total of files
|
|
206
|
+
# sent to S3 crosses another 100, to avoid overwhelming the connection.
|
|
207
|
+
S3_SLEEP_EVERY = 100
|
|
208
|
+
S3_SLEEP_SECONDS = 3
|
|
209
|
+
s3_uploaded_total = 0
|
|
210
|
+
next_sleep_threshold = S3_SLEEP_EVERY
|
|
164
211
|
for index, row in tqdm(
|
|
165
212
|
csv.transformed_df.iterrows(), total=csv.transformed_df.shape[0]
|
|
166
213
|
):
|
|
@@ -199,33 +246,26 @@ def processed_upload(api_key: str, csv_path: str, output_path: str, **kwargs):
|
|
|
199
246
|
result_row["upload_status"] = "Success"
|
|
200
247
|
result_row["error"] = None
|
|
201
248
|
|
|
202
|
-
#
|
|
249
|
+
# Upload every file for this recording concurrently. All
|
|
250
|
+
# presigned URLs in this response share one short expiry
|
|
251
|
+
# window, so uploading them sequentially risks the later
|
|
252
|
+
# files expiring (403). A thread pool sized to the CPU
|
|
253
|
+
# count keeps them within the window (I/O-bound work).
|
|
254
|
+
files_to_upload = res.get("response", [])
|
|
203
255
|
s3_errors = []
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
headers={
|
|
218
|
-
"x-amz-checksum-sha256": checksum,
|
|
219
|
-
"x-amz-sdk-checksum-algorithm": "SHA256",
|
|
220
|
-
"Content-Type": content_type,
|
|
221
|
-
},
|
|
222
|
-
)
|
|
223
|
-
if response.status_code != 200:
|
|
224
|
-
s3_errors.append(
|
|
225
|
-
f"S3 upload failed with status code {response.status_code} for file {recording}"
|
|
226
|
-
)
|
|
227
|
-
except Exception as ex:
|
|
228
|
-
s3_errors.append(str(ex))
|
|
256
|
+
if files_to_upload:
|
|
257
|
+
max_workers = min(len(files_to_upload), os.cpu_count() or 1)
|
|
258
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
259
|
+
for error in executor.map(
|
|
260
|
+
_put_file_to_s3, files_to_upload
|
|
261
|
+
):
|
|
262
|
+
if error:
|
|
263
|
+
s3_errors.append(error)
|
|
264
|
+
# Pause after every 100 files uploaded to S3.
|
|
265
|
+
s3_uploaded_total += len(files_to_upload)
|
|
266
|
+
while s3_uploaded_total >= next_sleep_threshold:
|
|
267
|
+
time.sleep(S3_SLEEP_SECONDS)
|
|
268
|
+
next_sleep_threshold += S3_SLEEP_EVERY
|
|
229
269
|
if s3_errors:
|
|
230
270
|
result_row["upload_status"] = "Failed"
|
|
231
271
|
result_row["error"] = "\n".join(s3_errors)
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/utils.py
RENAMED
|
@@ -6,6 +6,9 @@ import os
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import base64
|
|
8
8
|
import requests
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from requests.adapters import HTTPAdapter
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
9
12
|
from urllib.parse import urlsplit
|
|
10
13
|
from .language_choices import (
|
|
11
14
|
LANGUAGE_CHOICES,
|
|
@@ -14,6 +17,32 @@ from dateutil import parser
|
|
|
14
17
|
|
|
15
18
|
ALLOWED_COA_NAMES = ["MADRS", "YMRS", "PHQ-9", "GAD-7", "HAM-D17", "HAMD17"]
|
|
16
19
|
|
|
20
|
+
@lru_cache(maxsize=None)
|
|
21
|
+
def build_retry_session(total: int = 3, backoff_factor: float = 1) -> requests.Session:
|
|
22
|
+
"""Return a requests Session that retries transient network failures.
|
|
23
|
+
|
|
24
|
+
Retries connection errors (e.g. ConnectionResetError / 'Connection reset
|
|
25
|
+
by peer') and 5xx gateway responses with exponential backoff. Applied to
|
|
26
|
+
both the metadata POST and the S3 PUT uploads so a momentary blip on a
|
|
27
|
+
single record no longer fails the whole upload for that row.
|
|
28
|
+
"""
|
|
29
|
+
retry = Retry(
|
|
30
|
+
total=total,
|
|
31
|
+
connect=total,
|
|
32
|
+
read=total,
|
|
33
|
+
status=total,
|
|
34
|
+
backoff_factor=backoff_factor,
|
|
35
|
+
status_forcelist=[502, 503, 504],
|
|
36
|
+
allowed_methods=["PUT", "POST"],
|
|
37
|
+
raise_on_status=False,
|
|
38
|
+
)
|
|
39
|
+
# Increased pool size to accommodate concurrent ThreadPoolExecutor uploads
|
|
40
|
+
adapter = HTTPAdapter(max_retries=retry, pool_connections=100, pool_maxsize=100)
|
|
41
|
+
session = requests.Session()
|
|
42
|
+
session.mount("https://", adapter)
|
|
43
|
+
session.mount("http://", adapter)
|
|
44
|
+
return session
|
|
45
|
+
|
|
17
46
|
COA_ITEM_COUNTS = {
|
|
18
47
|
"MADRS": 10,
|
|
19
48
|
"YMRS": 10,
|
|
@@ -495,7 +524,10 @@ class UploadUtils:
|
|
|
495
524
|
self, api_key: str, url: str, headers: Dict[str, str], payload: Dict[str, Any]
|
|
496
525
|
) -> Dict[str, Any]:
|
|
497
526
|
try:
|
|
498
|
-
|
|
527
|
+
session = build_retry_session()
|
|
528
|
+
response = session.post(
|
|
529
|
+
url, headers=headers, json=payload, timeout=(10, 300)
|
|
530
|
+
)
|
|
499
531
|
res_json = response.json()
|
|
500
532
|
|
|
501
533
|
except Exception as ex:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/__init__.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/diarize_utils.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/willisdiarize.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/__init__.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/archive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|