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.
Files changed (33) hide show
  1. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/PKG-INFO +1 -1
  2. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/__version__.py +1 -1
  3. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/upload.py +68 -28
  4. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/utils.py +33 -1
  5. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/PKG-INFO +1 -1
  6. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/LICENSE +0 -0
  7. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/MANIFEST.in +0 -0
  8. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/README.md +0 -0
  9. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/RELEASE.md +0 -0
  10. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/requirements.txt +0 -0
  11. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/setup.cfg +0 -0
  12. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/setup.py +0 -0
  13. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/tests/test_diarize.py +0 -0
  14. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/tests/test_diarize_call_remaining.py +0 -0
  15. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/__init__.py +0 -0
  16. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/logging_setup.py +0 -0
  17. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/__init__.py +0 -0
  18. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/api.py +0 -0
  19. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/__init__.py +0 -0
  20. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/diarize_utils.py +0 -0
  21. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/willisdiarize.py +0 -0
  22. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/diarize/willisdiarize_call_remaining.py +0 -0
  23. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/exceptions.py +0 -0
  24. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/__init__.py +0 -0
  25. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/archive.py +0 -0
  26. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/services/metadata/language_choices.py +0 -0
  27. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/timer.py +0 -0
  28. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client/willisapi_client.py +0 -0
  29. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/SOURCES.txt +0 -0
  30. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/dependency_links.txt +0 -0
  31. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/not-zip-safe +0 -0
  32. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/requires.txt +0 -0
  33. {willisapi_client-1.9.7 → willisapi_client-1.9.8}/willisapi_client.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: willisapi_client
3
- Version: 1.9.7
3
+ Version: 1.9.8
4
4
  Summary: A Python client for willisapi
5
5
  Home-page: https://github.com/bklynhlth/willsiapi_client
6
6
  Author: bklynhlth
@@ -1,7 +1,7 @@
1
1
  """Version details for willisapi_client"""
2
2
 
3
3
  __client__ = "willisapi_client"
4
- __latestVersion__ = "1.9.7"
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"
@@ -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
- response = requests.put(
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
- # Handle S3 upload if presigned URL is provided
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
- for file_presigned in res.get("response", []):
205
- presigned = file_presigned.get("presigned")
206
- checksum = file_presigned.get("checksum")
207
- recording = file_presigned.get("recording")
208
- index = file_presigned.get("index")
209
- try:
210
- content_type, _ = mimetypes.guess_type(recording)
211
- if not content_type:
212
- content_type = "text/csv"
213
- with open(recording, "rb") as f:
214
- response = requests.put(
215
- presigned,
216
- data=f,
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)
@@ -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
- response = requests.post(url, headers=headers, json=payload)
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: willisapi-client
3
- Version: 1.9.7
3
+ Version: 1.9.8
4
4
  Summary: A Python client for willisapi
5
5
  Home-page: https://github.com/bklynhlth/willsiapi_client
6
6
  Author: bklynhlth