ovok-sdk 0.1.0.dev13__py3-none-any.whl

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 (53) hide show
  1. ovok_sdk/__init__.py +73 -0
  2. ovok_sdk/auth/__init__.py +0 -0
  3. ovok_sdk/auth/access_token.py +21 -0
  4. ovok_sdk/auth/client_credentials.py +41 -0
  5. ovok_sdk/auth/credentials.py +20 -0
  6. ovok_sdk/auth/pkce.py +28 -0
  7. ovok_sdk/auth/session.py +54 -0
  8. ovok_sdk/auth/session_store.py +77 -0
  9. ovok_sdk/auth/single_flight.py +44 -0
  10. ovok_sdk/auth/token_endpoint.py +45 -0
  11. ovok_sdk/auth/user_session.py +89 -0
  12. ovok_sdk/clock.py +11 -0
  13. ovok_sdk/config.py +118 -0
  14. ovok_sdk/errors.py +96 -0
  15. ovok_sdk/http/__init__.py +0 -0
  16. ovok_sdk/http/client.py +108 -0
  17. ovok_sdk/http/error_response.py +246 -0
  18. ovok_sdk/http/request.py +16 -0
  19. ovok_sdk/http/retry.py +59 -0
  20. ovok_sdk/http/transport.py +94 -0
  21. ovok_sdk/http/url.py +53 -0
  22. ovok_sdk/ovok.py +125 -0
  23. ovok_sdk/paging.py +117 -0
  24. ovok_sdk/py.typed +0 -0
  25. ovok_sdk/resources/__init__.py +0 -0
  26. ovok_sdk/resources/ai.py +55 -0
  27. ovok_sdk/resources/auth.py +177 -0
  28. ovok_sdk/resources/auth_sessions.py +27 -0
  29. ovok_sdk/resources/bots.py +16 -0
  30. ovok_sdk/resources/cms.py +159 -0
  31. ovok_sdk/resources/consent.py +42 -0
  32. ovok_sdk/resources/content.py +58 -0
  33. ovok_sdk/resources/documents.py +41 -0
  34. ovok_sdk/resources/fhir.py +89 -0
  35. ovok_sdk/resources/health.py +16 -0
  36. ovok_sdk/resources/invites.py +44 -0
  37. ovok_sdk/resources/measurement_builder.py +708 -0
  38. ovok_sdk/resources/measurements.py +649 -0
  39. ovok_sdk/resources/patient.py +81 -0
  40. ovok_sdk/resources/platform.py +141 -0
  41. ovok_sdk/resources/questionnaires.py +61 -0
  42. ovok_sdk/resources/registration.py +55 -0
  43. ovok_sdk/resources/sharing.py +59 -0
  44. ovok_sdk/resources/signals.py +75 -0
  45. ovok_sdk/resources/slim.py +431 -0
  46. ovok_sdk/resources/video_calls.py +63 -0
  47. ovok_sdk/resources/wearables.py +52 -0
  48. ovok_sdk/storage.py +25 -0
  49. ovok_sdk/values.py +10 -0
  50. ovok_sdk-0.1.0.dev13.dist-info/METADATA +287 -0
  51. ovok_sdk-0.1.0.dev13.dist-info/RECORD +53 -0
  52. ovok_sdk-0.1.0.dev13.dist-info/WHEEL +4 -0
  53. ovok_sdk-0.1.0.dev13.dist-info/licenses/LICENSE +661 -0
ovok_sdk/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ from ovok_sdk.auth.session import OvokReference, Session
2
+ from ovok_sdk.config import UnauthenticatedReason
3
+ from ovok_sdk.errors import (
4
+ FieldError,
5
+ OvokAuthenticationError,
6
+ OvokConfigError,
7
+ OvokConflictError,
8
+ OvokConnectionError,
9
+ OvokError,
10
+ OvokErrorDetails,
11
+ OvokNotFoundError,
12
+ OvokPermissionError,
13
+ OvokRateLimitError,
14
+ OvokServiceError,
15
+ OvokValidationError,
16
+ )
17
+ from ovok_sdk.http.transport import (
18
+ OvokRequest,
19
+ OvokResponse,
20
+ Transport,
21
+ UrllibTransport,
22
+ )
23
+ from ovok_sdk.ovok import Ovok
24
+ from ovok_sdk.paging import Page
25
+ from ovok_sdk.resources.auth import (
26
+ LoginResult,
27
+ MfaChallenge,
28
+ MfaRequired,
29
+ RegisterResult,
30
+ SignedIn,
31
+ )
32
+ from ovok_sdk.resources.measurement_builder import MeasurementTypeKey
33
+ from ovok_sdk.resources.registration import (
34
+ ClinicianInvite,
35
+ PatientRegistration,
36
+ Registration,
37
+ )
38
+ from ovok_sdk.storage import MemoryStorage, OvokStorage
39
+
40
+ __all__ = [
41
+ "ClinicianInvite",
42
+ "FieldError",
43
+ "LoginResult",
44
+ "MeasurementTypeKey",
45
+ "MemoryStorage",
46
+ "MfaChallenge",
47
+ "MfaRequired",
48
+ "Ovok",
49
+ "OvokAuthenticationError",
50
+ "OvokConfigError",
51
+ "OvokConflictError",
52
+ "OvokConnectionError",
53
+ "OvokError",
54
+ "OvokErrorDetails",
55
+ "OvokNotFoundError",
56
+ "OvokPermissionError",
57
+ "OvokRateLimitError",
58
+ "OvokReference",
59
+ "OvokRequest",
60
+ "OvokResponse",
61
+ "OvokServiceError",
62
+ "OvokStorage",
63
+ "OvokValidationError",
64
+ "Page",
65
+ "PatientRegistration",
66
+ "RegisterResult",
67
+ "Registration",
68
+ "Session",
69
+ "SignedIn",
70
+ "Transport",
71
+ "UnauthenticatedReason",
72
+ "UrllibTransport",
73
+ ]
File without changes
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from ovok_sdk.clock import MS_PER_SECOND
6
+
7
+ REFRESH_MARGIN_MS = 60_000
8
+
9
+
10
+ def expires_at_for(expires_in_seconds: float, now_ms: int) -> int:
11
+ return now_ms + round(expires_in_seconds * MS_PER_SECOND)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class AccessToken:
16
+ value: str = field(repr=False)
17
+ expires_at_ms: int
18
+
19
+ def is_stale_at(self, now_ms: int) -> bool:
20
+ """Stale a minute early, so a token never expires between the check and the request."""
21
+ return now_ms >= self.expires_at_ms - REFRESH_MARGIN_MS
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from ovok_sdk.auth.access_token import AccessToken, expires_at_for
4
+ from ovok_sdk.auth.single_flight import SingleFlight
5
+ from ovok_sdk.auth.token_endpoint import TokenEndpoint
6
+ from ovok_sdk.clock import Clock, epoch_ms
7
+ from ovok_sdk.config import MachineClient
8
+
9
+ CLIENT_CREDENTIALS_GRANT = "client_credentials"
10
+
11
+
12
+ class ClientCredentials:
13
+ """A machine client's own token, fetched from the OAuth endpoint and renewed before it expires."""
14
+
15
+ def __init__(self, endpoint: TokenEndpoint, client: MachineClient, clock: Clock = epoch_ms) -> None:
16
+ self._endpoint = endpoint
17
+ self._client = client
18
+ self._clock = clock
19
+ self._token: AccessToken | None = None
20
+ self._flight: SingleFlight[AccessToken] = SingleFlight()
21
+
22
+ def access_token(self) -> str:
23
+ token = self._token
24
+ if token is None or token.is_stale_at(self._clock()):
25
+ token = self._flight.run(self._request_token)
26
+ return token.value
27
+
28
+ def refresh(self) -> bool:
29
+ self._flight.run(self._request_token)
30
+ return True
31
+
32
+ def _request_token(self) -> AccessToken:
33
+ response = self._endpoint.request(
34
+ {
35
+ "grant_type": CLIENT_CREDENTIALS_GRANT,
36
+ "client_id": self._client.client_id,
37
+ "client_secret": self._client.client_secret,
38
+ }
39
+ )
40
+ self._token = AccessToken(response.access_token, expires_at_for(response.expires_in, self._clock()))
41
+ return self._token
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class Credentials(Protocol):
7
+ def access_token(self) -> str | None: ...
8
+
9
+ def refresh(self) -> bool: ...
10
+
11
+
12
+ class _NoCredentials:
13
+ def access_token(self) -> None:
14
+ return None
15
+
16
+ def refresh(self) -> bool:
17
+ return False
18
+
19
+
20
+ NO_CREDENTIALS: Credentials = _NoCredentials()
ovok_sdk/auth/pkce.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+ import string
7
+ from dataclasses import dataclass, field
8
+
9
+ VERIFIER_LENGTH = 128
10
+ UNRESERVED_CHARACTERS = string.ascii_letters + string.digits + "-._~"
11
+ ASCII = "ascii"
12
+ BASE64_PADDING = "="
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PkcePair:
17
+ code_verifier: str = field(repr=False)
18
+ code_challenge: str
19
+
20
+
21
+ def generate_pkce_pair() -> PkcePair:
22
+ verifier = "".join(secrets.choice(UNRESERVED_CHARACTERS) for _ in range(VERIFIER_LENGTH))
23
+ return PkcePair(verifier, code_challenge_for(verifier))
24
+
25
+
26
+ def code_challenge_for(verifier: str) -> str:
27
+ digest = hashlib.sha256(verifier.encode(ASCII)).digest()
28
+ return base64.urlsafe_b64encode(digest).decode(ASCII).rstrip(BASE64_PADDING)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field, replace
5
+ from typing import Any
6
+
7
+ from ovok_sdk.auth.access_token import AccessToken, expires_at_for
8
+ from ovok_sdk.auth.token_endpoint import TokenResponse
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class OvokReference:
13
+ reference: str
14
+ display: str | None = None
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Session:
19
+ access_token: str = field(repr=False)
20
+ refresh_token: str | None = field(repr=False)
21
+ expires_at_ms: int
22
+ project: OvokReference
23
+ profile: OvokReference
24
+
25
+ def is_stale_at(self, now_ms: int) -> bool:
26
+ return AccessToken(self.access_token, self.expires_at_ms).is_stale_at(now_ms)
27
+
28
+
29
+ def session_from_login(tokens: Mapping[str, Any], now_ms: int) -> Session:
30
+ """`tokens` is the camelCase body every Ovok sign-in route answers with."""
31
+ return Session(
32
+ access_token=tokens["accessToken"],
33
+ refresh_token=tokens.get("refreshToken"),
34
+ expires_at_ms=expires_at_for(tokens["expiresIn"], now_ms),
35
+ project=reference_from_json(tokens["project"]),
36
+ profile=reference_from_json(tokens["profile"]),
37
+ )
38
+
39
+
40
+ def session_after_refresh(session: Session, response: TokenResponse, now_ms: int) -> Session:
41
+ return replace(
42
+ session,
43
+ access_token=response.access_token,
44
+ refresh_token=response.refresh_token or session.refresh_token,
45
+ expires_at_ms=expires_at_for(response.expires_in, now_ms),
46
+ )
47
+
48
+
49
+ def reference_from_json(value: Mapping[str, Any]) -> OvokReference:
50
+ return OvokReference(value["reference"], value.get("display"))
51
+
52
+
53
+ def reference_to_json(reference: OvokReference) -> dict[str, Any]:
54
+ return {"reference": reference.reference, "display": reference.display}
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from ovok_sdk.auth.session import Session, reference_from_json, reference_to_json
7
+ from ovok_sdk.storage import OvokStorage
8
+ from ovok_sdk.values import finite_number
9
+
10
+ SESSION_KEY = "ovok:{tenant_code}:session"
11
+
12
+
13
+ class SessionStore:
14
+ """Keeps the session as JSON in the TypeScript SDK's shape, so stores are interchangeable."""
15
+
16
+ def __init__(self, storage: OvokStorage, key: str) -> None:
17
+ self._storage = storage
18
+ self._key = key
19
+
20
+ @classmethod
21
+ def for_tenant(cls, storage: OvokStorage, tenant_code: str) -> SessionStore:
22
+ """One key per tenant, so clearing a session never touches anything else in shared storage."""
23
+ return cls(storage, SESSION_KEY.format(tenant_code=tenant_code))
24
+
25
+ def load(self) -> Session | None:
26
+ raw = self._storage.get_item(self._key)
27
+ return None if raw is None else _session_from_json(raw)
28
+
29
+ def save(self, session: Session) -> None:
30
+ self._storage.set_item(self._key, _session_to_json(session))
31
+
32
+ def clear(self) -> None:
33
+ self._storage.remove_item(self._key)
34
+
35
+
36
+ def _session_to_json(session: Session) -> str:
37
+ return json.dumps(
38
+ {
39
+ "accessToken": session.access_token,
40
+ "refreshToken": session.refresh_token,
41
+ "expiresAt": session.expires_at_ms,
42
+ "project": reference_to_json(session.project),
43
+ "profile": reference_to_json(session.profile),
44
+ }
45
+ )
46
+
47
+
48
+ def _session_from_json(raw: str) -> Session | None:
49
+ try:
50
+ value = json.loads(raw)
51
+ except ValueError:
52
+ return None
53
+ return _session_from(value) if _is_complete(value) else None
54
+
55
+
56
+ def _session_from(value: dict[str, Any]) -> Session:
57
+ return Session(
58
+ access_token=value["accessToken"],
59
+ refresh_token=value.get("refreshToken"),
60
+ expires_at_ms=value["expiresAt"],
61
+ project=reference_from_json(value["project"]),
62
+ profile=reference_from_json(value["profile"]),
63
+ )
64
+
65
+
66
+ def _is_complete(value: Any) -> bool:
67
+ return (
68
+ isinstance(value, dict)
69
+ and isinstance(value.get("accessToken"), str)
70
+ and finite_number(value.get("expiresAt")) is not None
71
+ and _is_reference(value.get("project"))
72
+ and _is_reference(value.get("profile"))
73
+ )
74
+
75
+
76
+ def _is_reference(value: Any) -> bool:
77
+ return isinstance(value, dict) and isinstance(value.get("reference"), str)
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections.abc import Callable
5
+ from functools import partial
6
+ from typing import Generic, NoReturn, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+ Settled = Callable[[], T]
11
+
12
+
13
+ class SingleFlight(Generic[T]):
14
+ """Runs one call at a time; callers who arrive while it runs share its outcome instead of repeating it."""
15
+
16
+ def __init__(self) -> None:
17
+ self._lock = threading.Lock()
18
+ self._finished_calls = 0
19
+ self._latest: Settled[T] = _nothing_ran
20
+
21
+ def run(self, call: Callable[[], T]) -> T:
22
+ calls_seen = self._finished_calls
23
+ with self._lock:
24
+ if self._finished_calls == calls_seen:
25
+ self._latest = _settle(call)
26
+ self._finished_calls += 1
27
+ latest = self._latest
28
+ return latest()
29
+
30
+
31
+ def _settle(call: Callable[[], T]) -> Settled[T]:
32
+ try:
33
+ value = call()
34
+ except Exception as error:
35
+ return partial(_reraise, error)
36
+ return lambda: value
37
+
38
+
39
+ def _reraise(error: Exception) -> NoReturn:
40
+ raise error
41
+
42
+
43
+ def _nothing_ran() -> NoReturn:
44
+ raise RuntimeError("SingleFlight has run nothing yet.")
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+
8
+ from ovok_sdk.config import OvokSettings
9
+ from ovok_sdk.http.error_response import error_from_response
10
+ from ovok_sdk.http.transport import OvokRequest
11
+
12
+ TOKEN_PATH = "oauth2/token"
13
+ FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TokenResponse:
18
+ access_token: str = field(repr=False)
19
+ expires_in: float
20
+ refresh_token: str | None = field(default=None, repr=False)
21
+
22
+ @classmethod
23
+ def from_json(cls, body: Mapping[str, Any]) -> TokenResponse:
24
+ return cls(body["access_token"], body["expires_in"], body.get("refresh_token"))
25
+
26
+
27
+ class TokenEndpoint:
28
+ """Ovok's OAuth token endpoint: one plain form POST, without retries or credentials."""
29
+
30
+ def __init__(self, settings: OvokSettings) -> None:
31
+ self._settings = settings
32
+
33
+ def request(self, form: Mapping[str, str]) -> TokenResponse:
34
+ response = self._settings.transport(
35
+ OvokRequest(
36
+ method="POST",
37
+ url=f"{self._settings.base_url}{TOKEN_PATH}",
38
+ headers=FORM_HEADERS,
39
+ body=urlencode(form),
40
+ timeout=self._settings.timeout,
41
+ )
42
+ )
43
+ if response.is_success:
44
+ return TokenResponse.from_json(response.json())
45
+ raise error_from_response(response)
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any
5
+
6
+ from ovok_sdk.auth.session import Session, session_after_refresh, session_from_login
7
+ from ovok_sdk.auth.session_store import SessionStore
8
+ from ovok_sdk.auth.single_flight import SingleFlight
9
+ from ovok_sdk.auth.token_endpoint import TokenEndpoint
10
+ from ovok_sdk.clock import Clock, epoch_ms
11
+ from ovok_sdk.config import OvokSettings, UnauthenticatedReason
12
+ from ovok_sdk.errors import OvokError
13
+
14
+ REFRESH_TOKEN_GRANT = "refresh_token"
15
+ REJECTED_REFRESH_STATUSES = frozenset({400, 401, 403})
16
+
17
+
18
+ class UserSession:
19
+ """The signed-in user's tokens, kept in storage and refreshed a minute before they expire."""
20
+
21
+ def __init__(
22
+ self,
23
+ store: SessionStore,
24
+ endpoint: TokenEndpoint,
25
+ settings: OvokSettings,
26
+ clock: Clock = epoch_ms,
27
+ ) -> None:
28
+ self._store = store
29
+ self._endpoint = endpoint
30
+ self._settings = settings
31
+ self._clock = clock
32
+ self._flight: SingleFlight[bool] = SingleFlight()
33
+ self._listeners: list[Callable[[], None]] = []
34
+
35
+ def current(self) -> Session | None:
36
+ return self._store.load()
37
+
38
+ def begin(self, tokens: Mapping[str, Any]) -> Session:
39
+ session = session_from_login(tokens, self._clock())
40
+ self._store.save(session)
41
+ self._notify_change()
42
+ return session
43
+
44
+ def end(self) -> None:
45
+ self._store.clear()
46
+ self._notify_change()
47
+
48
+ def on_change(self, listener: Callable[[], None]) -> None:
49
+ self._listeners.append(listener)
50
+
51
+ def access_token(self) -> str | None:
52
+ if self._needs_refresh(self.current()):
53
+ self.refresh()
54
+ session = self.current()
55
+ return None if session is None else session.access_token
56
+
57
+ def refresh(self) -> bool:
58
+ return self._flight.run(self._exchange_refresh_token)
59
+
60
+ def _needs_refresh(self, session: Session | None) -> bool:
61
+ return session is not None and session.refresh_token is not None and session.is_stale_at(self._clock())
62
+
63
+ def _exchange_refresh_token(self) -> bool:
64
+ session = self.current()
65
+ if session is None or session.refresh_token is None:
66
+ return False
67
+ try:
68
+ response = self._endpoint.request(self._refresh_form(session.refresh_token))
69
+ except OvokError as error:
70
+ return self._sign_out_if_rejected(error)
71
+ self._store.save(session_after_refresh(session, response, self._clock()))
72
+ self._notify_change()
73
+ return True
74
+
75
+ def _notify_change(self) -> None:
76
+ for listener in tuple(self._listeners):
77
+ listener()
78
+
79
+ def _sign_out_if_rejected(self, error: OvokError) -> bool:
80
+ """Only a refused refresh token signs the user out; an outage keeps the session for a later retry."""
81
+ if error.status in REJECTED_REFRESH_STATUSES:
82
+ self.end()
83
+ self._settings.on_unauthenticated(UnauthenticatedReason(status=error.status))
84
+ return False
85
+ raise error
86
+
87
+ def _refresh_form(self, refresh_token: str) -> dict[str, str]:
88
+ client = {} if self._settings.client_id is None else {"client_id": self._settings.client_id}
89
+ return {"grant_type": REFRESH_TOKEN_GRANT, "refresh_token": refresh_token, **client}
ovok_sdk/clock.py ADDED
@@ -0,0 +1,11 @@
1
+ import time
2
+ from collections.abc import Callable
3
+
4
+ MS_PER_SECOND = 1000
5
+ NS_PER_MS = 1_000_000
6
+
7
+ Clock = Callable[[], int]
8
+
9
+
10
+ def epoch_ms() -> int:
11
+ return time.time_ns() // NS_PER_MS
ovok_sdk/config.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+
7
+ from ovok_sdk.errors import OvokConfigError, OvokErrorDetails
8
+ from ovok_sdk.http.transport import Transport, UrllibTransport
9
+ from ovok_sdk.storage import MemoryStorage, OvokStorage
10
+ from ovok_sdk.values import finite_number
11
+
12
+ DEFAULT_FHIR_PATH = "fhir"
13
+ DEFAULT_TIMEOUT_SECONDS = 30.0
14
+ DEFAULT_MAX_NETWORK_RETRIES = 2
15
+ ABSOLUTE_HTTP_URL = re.compile(r"https?://[^/\s]+", re.IGNORECASE)
16
+ PATH_SEPARATOR = "/"
17
+ REFRESH_REJECTED = "refresh-rejected"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class MachineClient:
22
+ client_id: str
23
+ client_secret: str = field(repr=False)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class UnauthenticatedReason:
28
+ status: int
29
+ type: str = REFRESH_REJECTED
30
+
31
+
32
+ OnUnauthenticated = Callable[[UnauthenticatedReason], None]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class OvokSettings:
37
+ base_url: str
38
+ tenant_code: str
39
+ fhir_path: str
40
+ storage: OvokStorage = field(repr=False)
41
+ transport: Transport = field(repr=False)
42
+ timeout: float
43
+ max_network_retries: int
44
+ client_id: str | None
45
+ machine_client: MachineClient | None
46
+ on_unauthenticated: OnUnauthenticated = field(repr=False)
47
+
48
+
49
+ def read_settings(
50
+ base_url: str,
51
+ tenant_code: str,
52
+ *,
53
+ fhir_path: str = DEFAULT_FHIR_PATH,
54
+ storage: OvokStorage | None = None,
55
+ client_id: str | None = None,
56
+ client_secret: str | None = None,
57
+ transport: Transport | None = None,
58
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
59
+ max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
60
+ on_unauthenticated: OnUnauthenticated | None = None,
61
+ ) -> OvokSettings:
62
+ """Checks every option up front, so a bad one fails at construction, not on the first request."""
63
+ return OvokSettings(
64
+ base_url=_read_base_url(base_url),
65
+ tenant_code=_require_text(tenant_code, "tenant_code"),
66
+ fhir_path=fhir_path.strip(PATH_SEPARATOR),
67
+ storage=storage or MemoryStorage(),
68
+ transport=transport or UrllibTransport(),
69
+ timeout=_require_positive(timeout, "timeout"),
70
+ max_network_retries=_require_count(max_network_retries, "max_network_retries"),
71
+ client_id=_optional_text(client_id, "client_id"),
72
+ machine_client=_read_machine_client(client_id, client_secret),
73
+ on_unauthenticated=on_unauthenticated or _ignore,
74
+ )
75
+
76
+
77
+ def _ignore(_reason: UnauthenticatedReason) -> None:
78
+ return None
79
+
80
+
81
+ def _config_error(message: str) -> OvokConfigError:
82
+ return OvokConfigError(OvokErrorDetails(message))
83
+
84
+
85
+ def _read_base_url(base_url: str) -> str:
86
+ if isinstance(base_url, str) and ABSOLUTE_HTTP_URL.match(base_url):
87
+ return base_url if base_url.endswith(PATH_SEPARATOR) else f"{base_url}{PATH_SEPARATOR}"
88
+ raise _config_error("base_url must be an absolute http(s) URL.")
89
+
90
+
91
+ def _read_machine_client(client_id: str | None, client_secret: str | None) -> MachineClient | None:
92
+ if client_secret is None:
93
+ return None
94
+ return MachineClient(_require_text(client_id, "client_id"), _require_text(client_secret, "client_secret"))
95
+
96
+
97
+ def _optional_text(value: str | None, name: str) -> str | None:
98
+ return None if value is None else _require_text(value, name)
99
+
100
+
101
+ def _require_text(value: str | None, name: str) -> str:
102
+ text = value.strip() if isinstance(value, str) else ""
103
+ if text:
104
+ return text
105
+ raise _config_error(f"{name} is required.")
106
+
107
+
108
+ def _require_positive(value: float, name: str) -> float:
109
+ number = finite_number(value)
110
+ if number is not None and number > 0:
111
+ return float(number)
112
+ raise _config_error(f"{name} must be a positive number.")
113
+
114
+
115
+ def _require_count(value: int, name: str) -> int:
116
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
117
+ return value
118
+ raise _config_error(f"{name} must be a whole number of at least 0.")