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.
Files changed (33) hide show
  1. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/PKG-INFO +1 -1
  2. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/__version__.py +1 -1
  3. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/upload.py +81 -32
  4. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/utils.py +40 -4
  5. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/PKG-INFO +1 -1
  6. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/LICENSE +0 -0
  7. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/MANIFEST.in +0 -0
  8. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/README.md +0 -0
  9. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/RELEASE.md +0 -0
  10. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/requirements.txt +0 -0
  11. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/setup.cfg +0 -0
  12. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/setup.py +0 -0
  13. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/tests/test_diarize.py +0 -0
  14. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/tests/test_diarize_call_remaining.py +0 -0
  15. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/__init__.py +0 -0
  16. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/logging_setup.py +0 -0
  17. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/__init__.py +0 -0
  18. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/api.py +0 -0
  19. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/__init__.py +0 -0
  20. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/diarize_utils.py +0 -0
  21. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/willisdiarize.py +0 -0
  22. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/diarize/willisdiarize_call_remaining.py +0 -0
  23. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/exceptions.py +0 -0
  24. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/__init__.py +0 -0
  25. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/archive.py +0 -0
  26. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/services/metadata/language_choices.py +0 -0
  27. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/timer.py +0 -0
  28. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client/willisapi_client.py +0 -0
  29. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/SOURCES.txt +0 -0
  30. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/dependency_links.txt +0 -0
  31. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/not-zip-safe +0 -0
  32. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/willisapi_client.egg-info/requires.txt +0 -0
  33. {willisapi_client-1.9.7 → willisapi_client-1.9.9}/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.9
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.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"
@@ -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
- # Register extensions that Python's default mimetypes table doesn't know about.
9
- # The server signs presigned URLs with these types, so the client must match.
10
- mimetypes.add_type("application/vnd.apache.parquet", ".parquet")
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, _ = mimetypes.guess_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
- response = requests.put(
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
- # Handle S3 upload if presigned URL is provided
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
- 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))
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)
@@ -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
- response = requests.post(url, headers=headers, json=payload)
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
- merged_key = os.path.join(os.path.dirname(orig_key), merged_name)
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
- parts = filepath.split(os.sep)
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 = os.sep.join(last_parts)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: willisapi-client
3
- Version: 1.9.7
3
+ Version: 1.9.9
4
4
  Summary: A Python client for willisapi
5
5
  Home-page: https://github.com/bklynhlth/willsiapi_client
6
6
  Author: bklynhlth