diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 17a2aa0..71be36f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,10 +15,10 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12"] steps: - name: Checkout vitallens-python - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python ${{ matrix.python-version }} @@ -26,7 +26,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Cache pip dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} @@ -57,6 +57,8 @@ jobs: env: VITALLENS_DEV_API_KEY: ${{ secrets.VITALLENS_DEV_API_KEY }} VITALLENS_API_ORIGIN: "test" + # The jobs share one dev API key and rate limit: run the heavy real-API tests in one job only + VITALLENS_HEAVY_E2E: ${{ matrix.python-version == '3.12' && '1' || '' }} run: | pytest @@ -65,15 +67,15 @@ jobs: runs-on: windows-latest steps: - name: Checkout vitallens-python - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.11" - name: Cache pip dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~\AppData\Local\pip\Cache key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db4f45e..085b9a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,14 +17,14 @@ jobs: contents: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: "3.11" - name: Install build tool run: python -m pip install --upgrade build diff --git a/README.md b/README.md index 13a37e0..2ad1bb9 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ For full API reference, advanced configuration, proxy usage, and result schemas, ## Troubleshooting -- **Prerequisites:** Requires `python>=3.10` and `ffmpeg` installed and on your `$PATH`. +- **Prerequisites:** Requires `python>=3.11` and `ffmpeg` installed and on your `$PATH`. - **Windows:** [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) are required. - **ONNX Runtime:** If you encounter installation issues with `onnxruntime`, try installing it via conda first: `conda install -c conda-forge onnxruntime`. diff --git a/docs/results.md b/docs/results.md index de235ed..3634b82 100644 --- a/docs/results.md +++ b/docs/results.md @@ -89,3 +89,16 @@ You might notice that not all keys (like `hrv_sdnn`) are present in every result > **Note:** HRV vitals are only available on `vitallens` version 2.0 or greater. +## Getting Accurate Results + +Estimates are only as good as the video they come from. For the best accuracy and confidence: + +- **Light the face well, with steady light.** Avoid flickering lights, changing screen colors and strong backlight. +- **Keep the subject within 0.5 m of the camera** and facing it, with the whole face in the frame (add the upper chest for respiratory rate). +- **Keep the subject and the camera still.** +- **Avoid heavy video compression.** It removes the subtle color changes the estimation relies on. + +See the [Accuracy & Guidelines](https://docs.rouast.com/guides/accuracy/) page for the details. + +Every value comes with a `confidence` between 0 and 1. As a guide, the VitalLens apps show heart rate and respiratory rate from a confidence of 0.8, and show HRV only when it and the heart rate both reach 0.8. Consider applying a similar rule: an HRV value next to a heart rate with low confidence is not reliable. + diff --git a/pyproject.toml b/pyproject.toml index 391c1e6..c34ba90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ ] description = "Vital sign estimation from facial video" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" keywords = ["python", "rppg", "vital signs monitoring", "heart rate", "pulse", "respiration"] license = {text = "MIT License"} classifiers = [ @@ -29,7 +29,7 @@ dependencies = [ "python-dotenv>=1.0", "pyyaml>=6.0.1", "requests>=2.32.0", - "vitallens-core==0.2.5", + "vitallens-core==0.3.4", ] dynamic = ["version"] @@ -44,3 +44,8 @@ Issues = "https://github.com/Rouast-Labs/vitallens-python/issues" packages = ["vitallens"] [tool.setuptools_scm] + +[tool.pytest.ini_options] +markers = [ + "integration: calls the real VitalLens API (needs VITALLENS_DEV_API_KEY); deselect with -m \"not integration\"", +] diff --git a/tests/test_client.py b/tests/test_client.py index dd9c9a1..84ba756 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,7 @@ import numpy as np import os import pytest +import time from unittest.mock import patch import sys @@ -42,6 +43,11 @@ def test_VitalLens(request, method_arg, detect_faces, file, export): assert data[0]['vitals']['heart_rate']['confidence'] == 1.0 os.remove(test_json_path) +# The CI jobs share one dev API key and its rate limit. The heavy real-API tests (many requests in a +# short time) therefore run in one CI job only, which sets VITALLENS_HEAVY_E2E. Set it locally to run them. +heavy_e2e = pytest.mark.skipif(not os.getenv("VITALLENS_HEAVY_E2E"), reason="Set VITALLENS_HEAVY_E2E=1 to run heavy real-API tests") + +@pytest.mark.integration def test_VitalLens_API(request): api_key = request.getfixturevalue('test_dev_api_key') vl = VitalLens(method=Method.VITALLENS, api_key=api_key, detect_faces=True, export_to_json=False) @@ -66,6 +72,35 @@ def test_VitalLens_API(request): np.testing.assert_allclose(result[0]['vitals']['hrv_rmssd']['value'], 30, atol=20) assert not os.path.exists("test.json") +@pytest.mark.integration +@heavy_e2e +def test_VitalLens_API_stream(request): + """Streams a video through the real API: frames in, results out, with state carried across requests.""" + api_key = request.getfixturevalue('test_dev_api_key') + test_video_ndarray = request.getfixturevalue('test_video_ndarray') + test_video_fps = request.getfixturevalue('test_video_fps') + test_video_faces = request.getfixturevalue('test_video_faces') + vl = VitalLens(method=Method.VITALLENS, api_key=api_key, export_to_json=False) + results = [] + n_frames = 360 # 12 s: enough for a heart rate + with vl.stream() as session: + for i in range(n_frames): + # Paced like a camera: the stream buffer drops frames if they arrive faster than the API can take them + session.push(test_video_ndarray[i], timestamp=i / test_video_fps, face=test_video_faces[i]) + time.sleep(1 / test_video_fps) + # Collect what the background thread processed, until it has gone quiet + while True: + result = session.get_result(block=True, timeout=20) + if result is None: + break + results.extend(result) + assert len(results) > 0 + n_samples = sum(len(r['waveforms']['ppg_waveform']['data']) for r in results if 'ppg_waveform' in r['waveforms']) + assert n_samples > 0.6 * n_frames + with_hr = [r for r in results if 'heart_rate' in r['vitals']] + assert len(with_hr) > 0 + np.testing.assert_allclose(with_hr[-1]['vitals']['heart_rate']['value'], 60, atol=10) + def test_VitalLens_proxies(): """Test that proxies are passed down correctly.""" proxies = {"https": "http://10.10.1.10:3128"} diff --git a/tests/test_utils.py b/tests/test_utils.py index 2f736f5..57dcb7c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -140,3 +140,31 @@ def test_check_faces_in_roi(scenario): expected = scenario[3] actual = check_faces_in_roi(faces=faces, roi=roi, percentage_required_inside_roi=percentage_required_inside_roi) assert expected == actual + +def test_get_api_origin_includes_version(monkeypatch): + from unittest import mock + from vitallens.utils import get_api_origin + monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False) + with mock.patch('vitallens.utils.importlib_metadata.version', return_value='0.6.2'): + assert get_api_origin() == 'vitallens-python_0.6.2' + +def test_get_api_origin_env_override_is_used_verbatim(monkeypatch): + from vitallens.utils import get_api_origin + monkeypatch.setenv('VITALLENS_API_ORIGIN', 'test') + assert get_api_origin() == 'test' + +def test_get_api_origin_without_installed_version(monkeypatch): + from unittest import mock + from vitallens.utils import get_api_origin + monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False) + with mock.patch('vitallens.utils.importlib_metadata.version', side_effect=Exception('not installed')): + assert get_api_origin() == 'vitallens-python' + +def test_get_api_origin_strips_characters_the_api_rejects(monkeypatch): + from unittest import mock + from vitallens.utils import get_api_origin + monkeypatch.delenv('VITALLENS_API_ORIGIN', raising=False) + with mock.patch('vitallens.utils.importlib_metadata.version', return_value='0.6.2.dev3+g1234abc.d2026 10/24'): + origin = get_api_origin() + assert origin == 'vitallens-python_0.6.2.dev3+g1234abc.d20261024' + assert len(origin) <= 64 diff --git a/tests/test_vitallens.py b/tests/test_vitallens.py index 8d483c2..bd07b7d 100644 --- a/tests/test_vitallens.py +++ b/tests/test_vitallens.py @@ -1,6 +1,7 @@ import base64 import json import numpy as np +import os from prpy.numpy.image import parse_image_inputs import pytest import requests @@ -10,10 +11,17 @@ import sys sys.path.append('../vitallens-python') -from vitallens.constants import API_MAX_FRAMES, API_MIN_FRAMES, API_FILE_URL, API_RESOLVE_URL +from vitallens.constants import API_MAX_FRAMES, API_MIN_FRAMES, API_FILE_URL, API_RESOLVE_URL, API_RESOLVE_TIMEOUT, API_STREAM_TIMEOUT, API_FILE_TIMEOUT from vitallens.methods.vitallens import VitalLensRPPGMethod, _resolve_model_config from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIError, VitalLensAPIQuotaExceededError +# The CI jobs share one dev API key and its rate limit. The heavy real-API tests (many requests in a +# short time) therefore run in one CI job only, which sets VITALLENS_HEAVY_E2E. Set it locally to run them. +heavy_e2e = pytest.mark.skipif(not os.getenv("VITALLENS_HEAVY_E2E"), reason="Set VITALLENS_HEAVY_E2E=1 to run heavy real-API tests") + +# The mocked API only accepts keys of a plausible length, like the real one. Not a real key. +FAKE_API_KEY = "fake-api-key-for-mocked-tests-0000000000" + def create_mock_response( status_code: int, json_data: dict @@ -122,9 +130,9 @@ def create_mock_api_response( @pytest.mark.parametrize("override_fps_target", [None, 15, 10]) @pytest.mark.parametrize("override_global_parse", [False, True]) @pytest.mark.parametrize("requested_model", ["vitallens-2.0"]) -@patch('requests.post', side_effect=create_mock_api_response) +@patch('requests.Session.post', side_effect=create_mock_api_response) def test_VitalLensRPPGMethod_file_mock(mock_post, mock_resolve_config, request, override_fps_target, override_global_parse, requested_model): - api_key = request.getfixturevalue('test_dev_api_key') + api_key = FAKE_API_KEY test_video_path = request.getfixturevalue('test_video_path') test_video_ndarray = request.getfixturevalue('test_video_ndarray') test_video_faces = request.getfixturevalue('test_video_faces') @@ -149,9 +157,9 @@ def test_VitalLensRPPGMethod_file_mock(mock_post, mock_resolve_config, request, @pytest.mark.parametrize("long", [True]) @pytest.mark.parametrize("override_global_parse", [False, True]) @pytest.mark.parametrize("requested_model", ["vitallens"]) -@patch('requests.post', side_effect=create_mock_api_response) +@patch('requests.Session.post', side_effect=create_mock_api_response) def test_VitalLensRPPGMethod_ndarray_mock(mock_post, mock_resolve_config, request, long, override_fps_target, override_global_parse, requested_model): - api_key = request.getfixturevalue('test_dev_api_key') + api_key = FAKE_API_KEY test_video_ndarray = request.getfixturevalue('test_video_ndarray') test_video_fps = request.getfixturevalue('test_video_fps') test_video_faces = request.getfixturevalue('test_video_faces') @@ -229,6 +237,7 @@ def test_VitalLensRPPGMethod_stream_mock(mock_post, mock_resolve_config, request assert 'X-State' in kwargs2['headers'] assert kwargs2['headers']['x-api-key'] == api_key +@pytest.mark.integration @pytest.mark.parametrize("process_signals", [True, False]) @pytest.mark.parametrize("n_frames", [16, 250]) def test_VitalLens_API_valid_response(request, process_signals, n_frames): @@ -282,6 +291,41 @@ def test_VitalLens_API_valid_response(request, process_signals, n_frames): state = np.asarray(response_body["state"]["data"]) assert state.shape == (256,) +@pytest.mark.integration +@heavy_e2e +def test_VitalLensRPPGMethod_large_upload_with_the_real_api(request): + """A multi-request /file upload with the real API. + + Deliberately not mocked: whether a few MB of video can be sent within the request timeout only + shows up on a real connection. A timeout that applied to the upload as a whole (the `requests` + library sends the body within the connect timeout) once cut such uploads off. + """ + api_key = request.getfixturevalue('test_dev_api_key') + test_video_ndarray = request.getfixturevalue('test_video_ndarray') + test_video_fps = request.getfixturevalue('test_video_fps') + test_video_faces = request.getfixturevalue('test_video_faces') + method = VitalLensRPPGMethod(api_key=api_key, requested_model_name="vitallens") + n_repeats = (API_MAX_FRAMES * 3) // test_video_ndarray.shape[0] + 1 + video = np.repeat(test_video_ndarray, repeats=n_repeats, axis=0) + faces = np.repeat(test_video_faces, repeats=n_repeats, axis=0) + data, conf, live = method.infer_batch(inputs=video, faces=faces, fps=test_video_fps) + assert data['ppg_waveform'].shape == (video.shape[0],) + assert conf['ppg_waveform'].shape == (video.shape[0],) + assert live.shape == (video.shape[0],) + +def test_file_timeout_lets_slow_uplinks_finish(): + """The /file upload budget must allow a full chunk to be sent over a slow mobile link.""" + chunk_bytes = API_MAX_FRAMES * 40 * 40 * 3 * 4 / 3 # base64 in the JSON payload + slow_uplink_bytes_per_s = 0.2e6 / 8 # 0.2 Mbit/s + assert chunk_bytes / slow_uplink_bytes_per_s <= API_FILE_TIMEOUT[0] + +def test_stream_timeout_lets_a_normal_request_finish(): + """A /stream request (a few dozen frames, gzip-compressed) must fit the budget on 1 Mbit/s.""" + request_bytes = 100 * 40 * 40 * 3 # a long queue of 100 frames, uncompressed + uplink_bytes_per_s = 1e6 / 8 + assert request_bytes / uplink_bytes_per_s <= API_STREAM_TIMEOUT[0] + +@pytest.mark.integration def test_VitalLens_API_wrong_api_key(request): test_video_ndarray = request.getfixturevalue('test_video_ndarray') test_video_fps = request.getfixturevalue('test_video_fps') @@ -294,6 +338,7 @@ def test_VitalLens_API_wrong_api_key(request): response = requests.post(API_FILE_URL, headers=headers, json=payload) assert response.status_code == 403 +@pytest.mark.integration def test_VitalLens_API_no_video(request): api_key = request.getfixturevalue('test_dev_api_key') headers = {"x-api-key": api_key} @@ -301,6 +346,7 @@ def test_VitalLens_API_no_video(request): response = requests.post(API_FILE_URL, headers=headers, json=payload) assert response.status_code == 400 +@pytest.mark.integration def test_VitalLens_API_no_parseable_video(request): api_key = request.getfixturevalue('test_dev_api_key') headers = {"x-api-key": api_key} @@ -320,11 +366,14 @@ def test_resolve_model_config_errors(): with pytest.raises(VitalLensAPIError, match="Server Error"): _resolve_model_config("valid_key", "vitallens") -def test_VitalLens_API_integration(mock_resolve_config, request): +@pytest.mark.integration +def test_VitalLens_API_resolve_model(request): + """Resolves the model with the real API.""" api_key = request.getfixturevalue('test_dev_api_key') method = VitalLensRPPGMethod(requested_model_name="vitallens", api_key=api_key) - assert method.resolved_model == 'vitallens-2.0' + assert method.resolved_model.startswith('vitallens') assert method.input_size == 40 + assert method.fps_target > 0 @patch('vitallens.methods.vitallens._resolve_model_config') def test_VitalLensRPPGMethod_init_errors(mock_resolve): @@ -378,4 +427,32 @@ def test_proxy_and_auth_offloading(mock_get, mock_post, request): method_offload.infer_batch(test_video_ndarray[:16], faces[:16], fps=30.0) args, kwargs = mock_post.call_args assert kwargs['proxies'] == proxies - assert 'x-api-key' not in kwargs['headers'] \ No newline at end of file + assert 'x-api-key' not in kwargs['headers'] + +# Requests need a timeout: without one, a stalled connection blocks forever. + +@patch('requests.get', side_effect=requests.exceptions.Timeout) +def test_resolve_model_config_timeout(mock_get): + with pytest.raises(VitalLensAPIError, match="timed out"): + _resolve_model_config(api_key="key", model_name="vitallens") + assert mock_get.call_args.kwargs['timeout'] == API_RESOLVE_TIMEOUT + +@patch('requests.Session.post', side_effect=requests.exceptions.Timeout) +def test_VitalLensRPPGMethod_stream_timeout(mock_post, mock_resolve_config): + method = VitalLensRPPGMethod(api_key="key", requested_model_name="vitallens") + frames = np.random.randint(0, 255, (16, 40, 40, 3), dtype=np.uint8) + with pytest.raises(VitalLensAPIError, match="timed out"): + method.infer_stream(frames=frames, fps=30.0, state=None) + assert mock_post.call_args.kwargs['timeout'] == API_STREAM_TIMEOUT + +@patch('requests.Session.post', side_effect=requests.exceptions.Timeout) +def test_VitalLensRPPGMethod_file_timeout(mock_post, mock_resolve_config): + method = VitalLensRPPGMethod(api_key="key", requested_model_name="vitallens") + n = 32 + frames = np.random.randint(0, 255, (n, 40, 40, 3), dtype=np.uint8) + faces = np.tile(np.array([[10, 10, 90, 90]], dtype=np.int64), (n, 1)) + with pytest.raises(VitalLensAPIError, match="timed out"): + method.process_api_batch( + batch=1, n_batches=1, inputs=frames, inputs_shape=(n, 100, 100, 3), + faces=faces, fps_target=30.0, fps=30.0, global_parse=True) + assert mock_post.call_args.kwargs['timeout'] == API_FILE_TIMEOUT diff --git a/vitallens/constants.py b/vitallens/constants.py index f2e7d5b..2f4e8c2 100644 --- a/vitallens/constants.py +++ b/vitallens/constants.py @@ -24,6 +24,7 @@ # API settings API_MIN_FRAMES = 16 +# TODO: 900 assumes 40x40 inputs. Derive /file split length from vc.compute_buffer_config(...).file_max (input_size aware, ~625 at 48x48). API_MAX_FRAMES = 900 API_OVERLAP = 30 API_FILE_URL = "https://api.rouast.com/vitallens-v3/file" @@ -36,6 +37,17 @@ if 'API_RESOLVE_URL' in os.environ: API_RESOLVE_URL = os.getenv('API_RESOLVE_URL') +# Request timeouts in seconds, as (connect, read). Without a timeout, a stalled connection blocks +# forever. Note that requests sends the request body within the *connect* timeout, so for uploads +# the first number is the time budget for sending the whole body, not just for connecting: +# /file uploads video chunks of about 6 MB and the API may take a while to process them: 300 s +# still lets an uplink of ~0.2 Mbit/s finish, so customers on slow mobile links are not cut off. +# A /stream request carries ~100 KB, and one that takes longer than this is of no use any more +# (newer frames queue behind it). +API_RESOLVE_TIMEOUT = (10, 30) +API_STREAM_TIMEOUT = (10, 10) +API_FILE_TIMEOUT = (300, 300) + # For local development against dev endpoints, create a `.env` file in the root # of the project and set the variables, for example: # API_FILE_URL="https://api-dev.rouast.com/vitallens-dev/file" diff --git a/vitallens/methods/vitallens.py b/vitallens/methods/vitallens.py index 006c9a3..275b8fc 100644 --- a/vitallens/methods/vitallens.py +++ b/vitallens/methods/vitallens.py @@ -30,16 +30,16 @@ import json import logging import requests -import os from typing import Union, Tuple import vitallens_core as vc from vitallens.constants import API_MAX_FRAMES, API_OVERLAP from vitallens.constants import API_FILE_URL, API_STREAM_URL, API_RESOLVE_URL +from vitallens.constants import API_RESOLVE_TIMEOUT, API_STREAM_TIMEOUT, API_FILE_TIMEOUT from vitallens.errors import VitalLensAPIKeyError, VitalLensAPIQuotaExceededError, VitalLensAPIError from vitallens.methods.rppg_method import RPPGMethod from vitallens.signal import reassemble_from_windows -from vitallens.utils import check_faces_in_roi +from vitallens.utils import check_faces_in_roi, get_api_origin def _resolve_model_config( api_key: str, @@ -62,7 +62,7 @@ def _resolve_model_config( if model_name != "vitallens": params['model'] = model_name try: - response = requests.get(API_RESOLVE_URL, headers=headers, params=params, proxies=proxies) + response = requests.get(API_RESOLVE_URL, headers=headers, params=params, proxies=proxies, timeout=API_RESOLVE_TIMEOUT) response.raise_for_status() data = response.json() config_dict = data['config'] @@ -75,6 +75,8 @@ def _resolve_model_config( roi_method=config_dict.get('roi_method', 'upper_body_cropped'), return_waveforms=['ppg_waveform', 'respiratory_waveform'] ) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to resolve the model config timed out. Please check your connection and try again.") from e except requests.exceptions.HTTPError as e: error_msg = f"Failed to resolve model config: Status {e.response.status_code}" try: @@ -242,7 +244,7 @@ def infer_stream(self, frames: np.ndarray, fps: float, state=None): } if self.api_key: headers["x-api-key"] = self.api_key - origin = os.getenv('VITALLENS_API_ORIGIN', 'vitallens-python') + origin = get_api_origin() headers["X-Origin"] = origin if self.requested_model_name: headers["X-Model"] = self.requested_model_name @@ -255,7 +257,10 @@ def infer_stream(self, frames: np.ndarray, fps: float, state=None): compressed_data = gzip.compress(raw_rgb_bytes) # Post the binary payload - response = self.http_session.post(API_STREAM_URL, headers=headers, data=compressed_data, proxies=self.proxies) + try: + response = self.http_session.post(API_STREAM_URL, headers=headers, data=compressed_data, proxies=self.proxies, timeout=API_STREAM_TIMEOUT) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to the VitalLens API timed out. The connection may be too slow for streaming.") from e if response.status_code != 200: response_body = response.json() @@ -352,12 +357,15 @@ def process_api_batch( headers = {} if self.api_key: headers["x-api-key"] = self.api_key - origin = os.getenv('VITALLENS_API_ORIGIN', 'vitallens-python') + origin = get_api_origin() payload = {"video": base64.b64encode(frames_ds.tobytes()).decode('utf-8'), "origin": origin} if self.requested_model_name: payload['model'] = self.requested_model_name # Ask API to process video - response = self.http_session.post(API_FILE_URL, headers=headers, json=payload, proxies=self.proxies) + try: + response = self.http_session.post(API_FILE_URL, headers=headers, json=payload, proxies=self.proxies, timeout=API_FILE_TIMEOUT) + except requests.exceptions.Timeout as e: + raise VitalLensAPIError("The request to the VitalLens API timed out. Please check your connection and try again.") from e response_body = json.loads(response.text) # Check if call was successful if response.status_code != 200: diff --git a/vitallens/utils.py b/vitallens/utils.py index 5f8f687..b22b8b8 100644 --- a/vitallens/utils.py +++ b/vitallens/utils.py @@ -18,12 +18,30 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import importlib.metadata as importlib_metadata import logging import numpy as np import os +import re from typing import Union import urllib.request +def get_api_origin() -> str: + """Get the origin label sent to the API: `vitallens-python_`. + + The version lets us see which client versions are in use. `VITALLENS_API_ORIGIN` overrides the label + and is sent as is (the API treats `test` specially). Without an installed version, e.g. when run from + a source checkout, only `vitallens-python` is sent. + """ + override = os.getenv('VITALLENS_API_ORIGIN') + if override: + return override + try: + version = re.sub(r'[^A-Za-z0-9._+-]', '', importlib_metadata.version('vitallens'))[:40] + except Exception: + return 'vitallens-python' + return f'vitallens-python_{version}' if version else 'vitallens-python' + def download_file(url: str, dest: str): """Download a file if necessary.