From e642e9177337d57abf33faab8d470d1cc2c285d2 Mon Sep 17 00:00:00 2001 From: Cody Mitchell Date: Sun, 13 Sep 2026 23:17:47 -0500 Subject: [PATCH] Keep V3 token caches local to each client --- actiapi/v3.py | 17 ++--- tests/test_v3_authentication.py | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 tests/test_v3_authentication.py diff --git a/actiapi/v3.py b/actiapi/v3.py index 3a081cc..99aab25 100644 --- a/actiapi/v3.py +++ b/actiapi/v3.py @@ -4,14 +4,12 @@ """ import logging -from collections import defaultdict from typing import Any, Dict, List, Literal, Optional, Union import requests from actiapi import ActiGraphClient -tokens: defaultdict[str, Optional[str]] = defaultdict(lambda: None) session = requests.Session() logger = logging.getLogger(__name__) @@ -23,6 +21,11 @@ class ActiGraphClientV3(ActiGraphClient): BASE_URL = "https://api.actigraphcorp.com" AUTH_API = "https://auth.actigraphcorp.com/connect/token" + def __init__(self, api_access_key: str, api_secret_key: str): + """Initialize a client with an independent token cache.""" + super().__init__(api_access_key, api_secret_key) + self._tokens: Dict[str, Optional[str]] = {} + @staticmethod def _generate_headers(token: str, raw: bool = False): headers = {} @@ -220,17 +223,15 @@ def get_sleep_summary( return results def _get_single(self, request: str, scope: str): - global tokens - if tokens[scope] is None: - tokens[scope] = self._get_access_token(scope) + if self._tokens.get(scope) is None: + self._tokens[scope] = self._get_access_token(scope) logger.info("Requesting %s", request) - headers = self._generate_headers(str(tokens[scope])) + headers = self._generate_headers(str(self._tokens[scope])) response = session.get(self.BASE_URL + request, headers=headers, stream=False) reply = validate_response(response) return reply def _get_paginated(self, request: str, scope: str): - global tokens results = [] offset = 0 limit = 100 @@ -243,7 +244,7 @@ def _get_paginated(self, request: str, scope: str): total_count = reply["totalCount"] except KeyError: - tokens[scope] = None + self._tokens.pop(scope, None) reply = self._get_single(request=paginated_request, scope=scope) if reply is None: break diff --git a/tests/test_v3_authentication.py b/tests/test_v3_authentication.py new file mode 100644 index 0000000..82342b1 --- /dev/null +++ b/tests/test_v3_authentication.py @@ -0,0 +1,119 @@ +import json +from collections import deque +from urllib.parse import parse_qs, urlsplit + +import pytest +import requests + +from actiapi.v3 import ActiGraphClientV3 + + +@pytest.fixture +def http(monkeypatch): + calls = {"auth": [], "get": [], "replies": deque()} + + def request(session, method, url, **kwargs): + response = requests.Response() + response.status_code = 200 + if method.upper() == "POST": + data = kwargs["data"] + calls["auth"].append((data["client_id"], data["scope"])) + payload = {"access_token": f"synthetic-{data['client_id']}-{data['scope']}"} + else: + calls["get"].append((url, kwargs["headers"]["Authorization"])) + if calls["replies"]: + payload = calls["replies"].popleft() + elif "offset=" in url: + payload = {"totalCount": 1, "items": [ + {"id": 1, "downloadUrl": "https://example.invalid/synthetic"} + ]} + else: + payload = {"id": 1} + response._content = json.dumps(payload).encode() + return response + + def forbidden_send(*args, **kwargs): + pytest.fail("A test attempted a real HTTP request") + + monkeypatch.setattr(requests.Session, "request", request) + monkeypatch.setattr(requests.Session, "send", forbidden_send) + return calls + + +def test_clients_use_their_own_credentials_at_the_same_scope(http): + first = ActiGraphClientV3("first", "synthetic-secret-a") + second = ActiGraphClientV3("second", "synthetic-secret-b") + for client in (first, second, first, second): + assert client.get_study_info(1) == {"id": 1} + assert [token for _, token in http["get"]] == [ + "Bearer synthetic-first-CentrePoint", + "Bearer synthetic-second-CentrePoint", + "Bearer synthetic-first-CentrePoint", + "Bearer synthetic-second-CentrePoint", + ] + assert http["auth"] == [("first", "CentrePoint"), ("second", "CentrePoint")] + + +def test_client_reuses_each_scope_independently(http): + client = ActiGraphClientV3("first", "synthetic-secret") + for _ in range(2): + for scope in ("CentrePoint", "Analytics", "DataAccess"): + client._get_single("/synthetic", scope) + assert http["auth"] == [ + ("first", "CentrePoint"), ("first", "Analytics"), ("first", "DataAccess") + ] + + +@pytest.mark.parametrize("method,args,scope", [ + ("get_study_info", (1,), "CentrePoint"), + ("get_studies", (), "CentrePoint"), + ("get_study_metadata", (1,), "CentrePoint"), + ("get_event_markers", (2, 1), "Analytics"), + ("get_minute_summary", (2, 1), "Analytics"), + ("get_daily_summary", (2, 1), "Analytics"), + ("get_sleep_summary", (2, 1), "Analytics"), + ("get_files", (2, 1), "DataAccess"), +]) +def test_public_v3_methods_keep_their_scope(http, method, args, scope): + client = ActiGraphClientV3("wrapper", "synthetic-secret") + result = getattr(client, method)(*args) + assert result + assert http["auth"] == [("wrapper", scope)] + assert http["get"][0][1] == f"Bearer synthetic-wrapper-{scope}" + + +def test_pagination_reuses_token_and_preserves_page_order(http): + http["replies"].extend([ + {"totalCount": 101, "items": [{"id": i} for i in range(100)]}, + {"totalCount": 101, "items": [{"id": 100}]}, + ]) + client = ActiGraphClientV3("pages", "synthetic-secret") + assert client.get_studies() == [{"id": i} for i in range(101)] + assert http["auth"] == [("pages", "CentrePoint")] + offsets = [parse_qs(urlsplit(url).query)["offset"] for url, _ in http["get"]] + assert offsets == [["0"], ["100"]] + + +def test_pagination_refresh_does_not_invalidate_another_client(http): + first = ActiGraphClientV3("first", "synthetic-secret-a") + second = ActiGraphClientV3("second", "synthetic-secret-b") + second.get_study_info(1) + http["replies"].extend([ + {"error": "synthetic-expired-token"}, + {"totalCount": 1, "items": [{"id": 1}]}, + ]) + assert first.get_studies() == [{"id": 1}] + second.get_study_info(1) + assert http["auth"] == [ + ("second", "CentrePoint"), ("first", "CentrePoint"), + ("first", "CentrePoint"), + ] + assert http["get"][-1][1] == "Bearer synthetic-second-CentrePoint" + + +def test_existing_malformed_page_refresh_remains_bounded(http): + http["replies"].extend([{}, {}]) + client = ActiGraphClientV3("bounded", "synthetic-secret") + with pytest.raises(KeyError, match="totalCount"): + client.get_studies() + assert len(http["get"]) == len(http["auth"]) == 2