willisapi-client 1.9.7__tar.gz → 1.9.9__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.9}/PKG-INFO +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/__version__.py +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/upload.py +81 -32
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/utils.py +40 -4
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/PKG-INFO +1 -1
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/LICENSE +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/MANIFEST.in +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/README.md +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/RELEASE.md +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/requirements.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/setup.cfg +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/setup.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/tests/test_diarize.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/tests/test_diarize_call_remaining.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/logging_setup.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/api.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/diarize_utils.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/willisdiarize.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/willisdiarize_call_remaining.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/exceptions.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/__init__.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/archive.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/language_choices.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/timer.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/willisapi_client.py +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/SOURCES.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/dependency_links.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/not-zip-safe +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/requires.txt +0 -0
- {willisapi_client-1.9.7 → willisapi_client-1.9.9}/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.9"
|
|
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.9}/willisapi_client/services/metadata/upload.py
RENAMED
|
@@ -4,10 +4,19 @@ 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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
10
|
+
# The server signs presigned URLs with Content-Types guessed from Python's
|
|
11
|
+
# built-in mimetypes table (on Linux), so the client must produce identical
|
|
12
|
+
# guesses. A private MimeTypes instance holds only that built-in table —
|
|
13
|
+
# the module-level mimetypes.guess_type() on Windows also reads the registry
|
|
14
|
+
# (e.g. .csv -> application/vnd.ms-excel, .wav -> audio/wav), which makes the
|
|
15
|
+
# PUT's Content-Type differ from the signed one and S3 reject the upload with
|
|
16
|
+
# SignatureDoesNotMatch. Also register extensions the built-in table lacks,
|
|
17
|
+
# mirroring the server.
|
|
18
|
+
_MIME_TYPES = mimetypes.MimeTypes()
|
|
19
|
+
_MIME_TYPES.add_type("application/vnd.apache.parquet", ".parquet")
|
|
11
20
|
|
|
12
21
|
from willisapi_client.timer import measure
|
|
13
22
|
from willisapi_client.willisapi_client import WillisapiClient
|
|
@@ -16,6 +25,7 @@ from willisapi_client.services.metadata.utils import (
|
|
|
16
25
|
MetadataValidation,
|
|
17
26
|
ProcessedMetadataValidation,
|
|
18
27
|
UploadUtils,
|
|
28
|
+
build_retry_session,
|
|
19
29
|
find_files_with_pattern,
|
|
20
30
|
get_last_n_directories,
|
|
21
31
|
)
|
|
@@ -27,6 +37,44 @@ from willisapi_client.services.metadata.archive import (
|
|
|
27
37
|
VALID_SCORE_TYPES = ["rater", "reviewer"]
|
|
28
38
|
|
|
29
39
|
|
|
40
|
+
def _put_file_to_s3(file_presigned: dict):
|
|
41
|
+
"""Upload a single file to its presigned S3 URL.
|
|
42
|
+
|
|
43
|
+
Returns an error string if the upload fails, otherwise None. Presigned
|
|
44
|
+
URLs from a single recording are all signed at once and share one expiry
|
|
45
|
+
window, so callers should run these concurrently to fit within it.
|
|
46
|
+
"""
|
|
47
|
+
presigned = file_presigned.get("presigned")
|
|
48
|
+
checksum = file_presigned.get("checksum")
|
|
49
|
+
recording = file_presigned.get("recording")
|
|
50
|
+
try:
|
|
51
|
+
content_type, _ = _MIME_TYPES.guess_type(recording)
|
|
52
|
+
if not content_type:
|
|
53
|
+
# Mirror the server's fallback — it signs the presigned URL with
|
|
54
|
+
# this Content-Type when the extension is unknown.
|
|
55
|
+
content_type = "application/octet-stream"
|
|
56
|
+
with open(recording, "rb") as f:
|
|
57
|
+
session = build_retry_session()
|
|
58
|
+
response = session.put(
|
|
59
|
+
presigned,
|
|
60
|
+
data=f,
|
|
61
|
+
headers={
|
|
62
|
+
"x-amz-checksum-sha256": checksum,
|
|
63
|
+
"x-amz-sdk-checksum-algorithm": "SHA256",
|
|
64
|
+
"Content-Type": content_type,
|
|
65
|
+
},
|
|
66
|
+
timeout=(10, 300),
|
|
67
|
+
)
|
|
68
|
+
if response.status_code != 200:
|
|
69
|
+
return (
|
|
70
|
+
f"S3 upload failed with status code {response.status_code} "
|
|
71
|
+
f"for file {recording}: {response.text}"
|
|
72
|
+
)
|
|
73
|
+
except Exception as ex:
|
|
74
|
+
return str(ex)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
30
78
|
@measure
|
|
31
79
|
def upload(api_key: str, csv_path: str, **kwargs):
|
|
32
80
|
|
|
@@ -69,13 +117,14 @@ def upload(api_key: str, csv_path: str, **kwargs):
|
|
|
69
117
|
presigned = res.get("response", {}).get("presigned")
|
|
70
118
|
if presigned:
|
|
71
119
|
try:
|
|
72
|
-
content_type, _ =
|
|
120
|
+
content_type, _ = _MIME_TYPES.guess_type(
|
|
73
121
|
payload.get("filename")
|
|
74
122
|
)
|
|
75
123
|
if not content_type:
|
|
76
124
|
content_type = "application/octet-stream"
|
|
77
125
|
with open(row.file_path, "rb") as f:
|
|
78
|
-
|
|
126
|
+
session = build_retry_session()
|
|
127
|
+
response = session.put(
|
|
79
128
|
presigned,
|
|
80
129
|
data=f,
|
|
81
130
|
headers={
|
|
@@ -85,13 +134,14 @@ def upload(api_key: str, csv_path: str, **kwargs):
|
|
|
85
134
|
"x-amz-sdk-checksum-algorithm": "SHA256",
|
|
86
135
|
"Content-Type": content_type,
|
|
87
136
|
},
|
|
137
|
+
timeout=(10, 300),
|
|
88
138
|
)
|
|
89
139
|
if response.status_code == 200:
|
|
90
140
|
result_row["upload_status"] = "Success"
|
|
91
141
|
else:
|
|
92
142
|
result_row["upload_status"] = "Failed"
|
|
93
143
|
result_row["error"] = (
|
|
94
|
-
f"S3 upload failed with status code {response.status_code}"
|
|
144
|
+
f"S3 upload failed with status code {response.status_code}: {response.text}"
|
|
95
145
|
)
|
|
96
146
|
except Exception as ex:
|
|
97
147
|
result_row["upload_status"] = "Failed"
|
|
@@ -161,6 +211,12 @@ def processed_upload(api_key: str, csv_path: str, output_path: str, **kwargs):
|
|
|
161
211
|
)
|
|
162
212
|
|
|
163
213
|
results = []
|
|
214
|
+
# Throttle S3 uploads: pause 3s each time the running total of files
|
|
215
|
+
# sent to S3 crosses another 100, to avoid overwhelming the connection.
|
|
216
|
+
S3_SLEEP_EVERY = 100
|
|
217
|
+
S3_SLEEP_SECONDS = 3
|
|
218
|
+
s3_uploaded_total = 0
|
|
219
|
+
next_sleep_threshold = S3_SLEEP_EVERY
|
|
164
220
|
for index, row in tqdm(
|
|
165
221
|
csv.transformed_df.iterrows(), total=csv.transformed_df.shape[0]
|
|
166
222
|
):
|
|
@@ -199,33 +255,26 @@ def processed_upload(api_key: str, csv_path: str, output_path: str, **kwargs):
|
|
|
199
255
|
result_row["upload_status"] = "Success"
|
|
200
256
|
result_row["error"] = None
|
|
201
257
|
|
|
202
|
-
#
|
|
258
|
+
# Upload every file for this recording concurrently. All
|
|
259
|
+
# presigned URLs in this response share one short expiry
|
|
260
|
+
# window, so uploading them sequentially risks the later
|
|
261
|
+
# files expiring (403). A thread pool sized to the CPU
|
|
262
|
+
# count keeps them within the window (I/O-bound work).
|
|
263
|
+
files_to_upload = res.get("response", [])
|
|
203
264
|
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))
|
|
265
|
+
if files_to_upload:
|
|
266
|
+
max_workers = min(len(files_to_upload), os.cpu_count() or 1)
|
|
267
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
268
|
+
for error in executor.map(
|
|
269
|
+
_put_file_to_s3, files_to_upload
|
|
270
|
+
):
|
|
271
|
+
if error:
|
|
272
|
+
s3_errors.append(error)
|
|
273
|
+
# Pause after every 100 files uploaded to S3.
|
|
274
|
+
s3_uploaded_total += len(files_to_upload)
|
|
275
|
+
while s3_uploaded_total >= next_sleep_threshold:
|
|
276
|
+
time.sleep(S3_SLEEP_SECONDS)
|
|
277
|
+
next_sleep_threshold += S3_SLEEP_EVERY
|
|
229
278
|
if s3_errors:
|
|
230
279
|
result_row["upload_status"] = "Failed"
|
|
231
280
|
result_row["error"] = "\n".join(s3_errors)
|
{willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/utils.py
RENAMED
|
@@ -3,9 +3,13 @@ import numpy as np
|
|
|
3
3
|
from typing import List, Dict, Any, Tuple
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import posixpath
|
|
6
7
|
import hashlib
|
|
7
8
|
import base64
|
|
8
9
|
import requests
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from requests.adapters import HTTPAdapter
|
|
12
|
+
from urllib3.util.retry import Retry
|
|
9
13
|
from urllib.parse import urlsplit
|
|
10
14
|
from .language_choices import (
|
|
11
15
|
LANGUAGE_CHOICES,
|
|
@@ -14,6 +18,32 @@ from dateutil import parser
|
|
|
14
18
|
|
|
15
19
|
ALLOWED_COA_NAMES = ["MADRS", "YMRS", "PHQ-9", "GAD-7", "HAM-D17", "HAMD17"]
|
|
16
20
|
|
|
21
|
+
@lru_cache(maxsize=None)
|
|
22
|
+
def build_retry_session(total: int = 3, backoff_factor: float = 1) -> requests.Session:
|
|
23
|
+
"""Return a requests Session that retries transient network failures.
|
|
24
|
+
|
|
25
|
+
Retries connection errors (e.g. ConnectionResetError / 'Connection reset
|
|
26
|
+
by peer') and 5xx gateway responses with exponential backoff. Applied to
|
|
27
|
+
both the metadata POST and the S3 PUT uploads so a momentary blip on a
|
|
28
|
+
single record no longer fails the whole upload for that row.
|
|
29
|
+
"""
|
|
30
|
+
retry = Retry(
|
|
31
|
+
total=total,
|
|
32
|
+
connect=total,
|
|
33
|
+
read=total,
|
|
34
|
+
status=total,
|
|
35
|
+
backoff_factor=backoff_factor,
|
|
36
|
+
status_forcelist=[502, 503, 504],
|
|
37
|
+
allowed_methods=["PUT", "POST"],
|
|
38
|
+
raise_on_status=False,
|
|
39
|
+
)
|
|
40
|
+
# Increased pool size to accommodate concurrent ThreadPoolExecutor uploads
|
|
41
|
+
adapter = HTTPAdapter(max_retries=retry, pool_connections=100, pool_maxsize=100)
|
|
42
|
+
session = requests.Session()
|
|
43
|
+
session.mount("https://", adapter)
|
|
44
|
+
session.mount("http://", adapter)
|
|
45
|
+
return session
|
|
46
|
+
|
|
17
47
|
COA_ITEM_COUNTS = {
|
|
18
48
|
"MADRS": 10,
|
|
19
49
|
"YMRS": 10,
|
|
@@ -495,7 +525,10 @@ class UploadUtils:
|
|
|
495
525
|
self, api_key: str, url: str, headers: Dict[str, str], payload: Dict[str, Any]
|
|
496
526
|
) -> Dict[str, Any]:
|
|
497
527
|
try:
|
|
498
|
-
|
|
528
|
+
session = build_retry_session()
|
|
529
|
+
response = session.post(
|
|
530
|
+
url, headers=headers, json=payload, timeout=(10, 300)
|
|
531
|
+
)
|
|
499
532
|
res_json = response.json()
|
|
500
533
|
|
|
501
534
|
except Exception as ex:
|
|
@@ -725,7 +758,8 @@ class ProcessedMetadataValidation:
|
|
|
725
758
|
orig_bucket, orig_key = _get_bucket_n_key_path_from_s3url(
|
|
726
759
|
grp.iloc[0]["recording"]
|
|
727
760
|
)
|
|
728
|
-
|
|
761
|
+
# S3 keys always use "/" — os.path.join would insert "\" on Windows.
|
|
762
|
+
merged_key = posixpath.join(posixpath.dirname(orig_key), merged_name)
|
|
729
763
|
|
|
730
764
|
# Create new URI (no upload, logical merge only)
|
|
731
765
|
new_uri = f"s3://{orig_bucket}/{merged_key}"
|
|
@@ -778,9 +812,11 @@ def get_last_n_directories(filepath, n=3):
|
|
|
778
812
|
result = None
|
|
779
813
|
error = None
|
|
780
814
|
try:
|
|
781
|
-
|
|
815
|
+
# Normalize Windows backslashes so the result is always a
|
|
816
|
+
# forward-slash S3 key regardless of the local OS.
|
|
817
|
+
parts = filepath.replace("\\", "/").split("/")
|
|
782
818
|
last_parts = parts[-(n + 1) :]
|
|
783
|
-
result =
|
|
819
|
+
result = "/".join(last_parts)
|
|
784
820
|
except Exception as e:
|
|
785
821
|
error = "Could not extract container name and container version from file path"
|
|
786
822
|
return result, error
|
|
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.9}/willisapi_client/services/diarize/__init__.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/diarize_utils.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.9}/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.9}/willisapi_client/services/metadata/__init__.py
RENAMED
|
File without changes
|
{willisapi_client-1.9.7 → willisapi_client-1.9.9}/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.9}/willisapi_client.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|