ovok-sdk 0.1.0.dev13__tar.gz → 0.1.0.dev19__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 (69) hide show
  1. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/PKG-INFO +8 -3
  2. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/README.md +7 -2
  3. ovok_sdk-0.1.0.dev19/pyproject.toml +58 -0
  4. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/pyproject.toml.orig +26 -1
  5. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/__init__.py +2 -0
  6. ovok_sdk-0.1.0.dev19/src/ovok_sdk/auth/__init__.py +1 -0
  7. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/access_token.py +10 -0
  8. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/client_credentials.py +15 -1
  9. ovok_sdk-0.1.0.dev19/src/ovok_sdk/auth/credentials.py +28 -0
  10. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/pkce.py +11 -0
  11. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/session.py +32 -1
  12. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/session_store.py +11 -1
  13. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/single_flight.py +10 -0
  14. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/token_endpoint.py +27 -1
  15. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/user_session.py +33 -1
  16. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/clock.py +3 -0
  17. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/config.py +41 -1
  18. ovok_sdk-0.1.0.dev19/src/ovok_sdk/errors.py +142 -0
  19. ovok_sdk-0.1.0.dev19/src/ovok_sdk/http/__init__.py +1 -0
  20. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/client.py +31 -2
  21. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/error_response.py +11 -1
  22. ovok_sdk-0.1.0.dev19/src/ovok_sdk/http/request.py +26 -0
  23. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/retry.py +17 -1
  24. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/transport.py +48 -1
  25. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/url.py +14 -2
  26. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/ovok.py +27 -1
  27. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/paging.py +24 -1
  28. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/__init__.py +1 -0
  29. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/ai.py +139 -0
  30. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/auth.py +156 -7
  31. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/auth_sessions.py +18 -2
  32. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/bots.py +16 -0
  33. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/cms.py +105 -0
  34. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/consent.py +51 -0
  35. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/content.py +143 -0
  36. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/documents.py +108 -0
  37. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/fhir.py +93 -3
  38. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/health.py +33 -0
  39. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/invites.py +41 -0
  40. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/measurement_builder.py +20 -0
  41. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/measurements.py +148 -0
  42. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/patient.py +79 -0
  43. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/platform.py +348 -0
  44. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/questionnaires.py +45 -0
  45. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/registration.py +47 -2
  46. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/sharing.py +62 -0
  47. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/signals.py +52 -0
  48. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/slim.py +625 -1
  49. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/video_calls.py +170 -0
  50. ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/wearables.py +132 -0
  51. ovok_sdk-0.1.0.dev19/src/ovok_sdk/storage.py +40 -0
  52. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/values.py +2 -0
  53. ovok_sdk-0.1.0.dev13/pyproject.toml +0 -21
  54. ovok_sdk-0.1.0.dev13/src/ovok_sdk/auth/__init__.py +0 -0
  55. ovok_sdk-0.1.0.dev13/src/ovok_sdk/auth/credentials.py +0 -20
  56. ovok_sdk-0.1.0.dev13/src/ovok_sdk/errors.py +0 -96
  57. ovok_sdk-0.1.0.dev13/src/ovok_sdk/http/__init__.py +0 -0
  58. ovok_sdk-0.1.0.dev13/src/ovok_sdk/http/request.py +0 -16
  59. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/__init__.py +0 -0
  60. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/ai.py +0 -55
  61. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/content.py +0 -58
  62. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/documents.py +0 -41
  63. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/health.py +0 -16
  64. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/platform.py +0 -141
  65. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/video_calls.py +0 -63
  66. ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/wearables.py +0 -52
  67. ovok_sdk-0.1.0.dev13/src/ovok_sdk/storage.py +0 -25
  68. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/LICENSE +0 -0
  69. {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ovok-sdk
3
- Version: 0.1.0.dev13
3
+ Version: 0.1.0.dev19
4
4
  Summary: Python SDK for integrating Ovok Core healthcare services.
5
5
  Author: Actimi
6
6
  Author-email: Actimi <info@actimi.com>
@@ -255,15 +255,20 @@ the same details in the body.
255
255
 
256
256
  ## Documentation
257
257
 
258
- See the [Python SDK documentation](https://docs.ovok.com/python-sdk) for setup and
259
- client reference.
258
+ See the [Python SDK documentation](https://docs.ovok.com/python-sdk) for guides and the
259
+ [API reference](https://docs.ovok.com/python-sdk/reference/index.html), generated from the docstrings.
260
260
 
261
261
  ## Development
262
262
 
263
263
  ```bash
264
264
  uv run python -m unittest discover -s tests
265
+ uv run ruff check
266
+ uv run --group docs mkdocs build
265
267
  ```
266
268
 
269
+ `ruff check` fails on a missing or malformed docstring. `mkdocs build` writes the API reference
270
+ to `site/`; see [AGENTS.md](AGENTS.md) for the docs rule.
271
+
267
272
  Tests never reach a server: `tests/fake_transport.py` queues replies, and `tests/route_harness.py` turns a
268
273
  namespace call into a line such as `"GET v1/me/consent/legal"` (`request_line`) or its JSON body
269
274
  (`request_body`).
@@ -242,15 +242,20 @@ the same details in the body.
242
242
 
243
243
  ## Documentation
244
244
 
245
- See the [Python SDK documentation](https://docs.ovok.com/python-sdk) for setup and
246
- client reference.
245
+ See the [Python SDK documentation](https://docs.ovok.com/python-sdk) for guides and the
246
+ [API reference](https://docs.ovok.com/python-sdk/reference/index.html), generated from the docstrings.
247
247
 
248
248
  ## Development
249
249
 
250
250
  ```bash
251
251
  uv run python -m unittest discover -s tests
252
+ uv run ruff check
253
+ uv run --group docs mkdocs build
252
254
  ```
253
255
 
256
+ `ruff check` fails on a missing or malformed docstring. `mkdocs build` writes the API reference
257
+ to `site/`; see [AGENTS.md](AGENTS.md) for the docs rule.
258
+
254
259
  Tests never reach a server: `tests/fake_transport.py` queues replies, and `tests/route_harness.py` turns a
255
260
  namespace call into a line such as `"GET v1/me/consent/legal"` (`request_line`) or its JSON body
256
261
  (`request_body`).
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "ovok-sdk"
3
+ version = "0.1.0.dev19"
4
+ description = "Python SDK for integrating Ovok Core healthcare services."
5
+ readme = "README.md"
6
+ license = "AGPL-3.0-only"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+
11
+ [[project.authors]]
12
+ name = "Actimi"
13
+ email = "info@actimi.com"
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/Actimi/ovok-python-sdk"
17
+ Documentation = "https://docs.ovok.com/python-sdk"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.25,<0.12.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [dependency-groups]
24
+ dev = ["ruff>=0.17.3,<0.18"]
25
+ docs = [
26
+ "mkdocs>=1.6,<2",
27
+ "mkdocs-material>=9.6,<10",
28
+ "mkdocstrings[python]>=1",
29
+ ]
30
+
31
+ [tool.uv]
32
+ default-groups = ["dev"]
33
+
34
+ [tool.ruff]
35
+ target-version = "py310"
36
+
37
+ [tool.ruff.lint]
38
+ select = [
39
+ "ANN",
40
+ "D",
41
+ ]
42
+ ignore = [
43
+ "ANN401",
44
+ "D107",
45
+ ]
46
+
47
+ [tool.ruff.lint.per-file-ignores]
48
+ "scripts/**" = [
49
+ "ANN",
50
+ "D",
51
+ ]
52
+ "tests/**" = [
53
+ "ANN",
54
+ "D",
55
+ ]
56
+
57
+ [tool.ruff.lint.pydocstyle]
58
+ convention = "google"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ovok-sdk"
3
- version = "0.1.0.dev13"
3
+ version = "0.1.0.dev19"
4
4
  description = "Python SDK for integrating Ovok Core healthcare services."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -18,3 +18,28 @@ Documentation = "https://docs.ovok.com/python-sdk"
18
18
  [build-system]
19
19
  requires = ["uv_build>=0.11.25,<0.12.0"]
20
20
  build-backend = "uv_build"
21
+
22
+ [dependency-groups]
23
+ dev = ["ruff>=0.17.3,<0.18"]
24
+ docs = [
25
+ "mkdocs>=1.6,<2",
26
+ "mkdocs-material>=9.6,<10",
27
+ "mkdocstrings[python]>=1",
28
+ ]
29
+
30
+ [tool.uv]
31
+ default-groups = ["dev"]
32
+
33
+ [tool.ruff]
34
+ target-version = "py310"
35
+
36
+ [tool.ruff.lint]
37
+ select = ["ANN", "D"]
38
+ ignore = ["ANN401", "D107"]
39
+
40
+ [tool.ruff.lint.per-file-ignores]
41
+ "scripts/**" = ["ANN", "D"]
42
+ "tests/**" = ["ANN", "D"]
43
+
44
+ [tool.ruff.lint.pydocstyle]
45
+ convention = "google"
@@ -1,3 +1,5 @@
1
+ """Python SDK for Ovok Core: FHIR R4 and platform APIs through one tenant-scoped client."""
2
+
1
3
  from ovok_sdk.auth.session import OvokReference, Session
2
4
  from ovok_sdk.config import UnauthenticatedReason
3
5
  from ovok_sdk.errors import (
@@ -0,0 +1 @@
1
+ """Sessions, tokens and the credentials that sign requests."""
@@ -1,3 +1,5 @@
1
+ """OAuth access tokens and their expiry."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from dataclasses import dataclass, field
@@ -8,11 +10,19 @@ REFRESH_MARGIN_MS = 60_000
8
10
 
9
11
 
10
12
  def expires_at_for(expires_in_seconds: float, now_ms: int) -> int:
13
+ """Return the epoch-millisecond expiry of a token that lives `expires_in_seconds` from `now_ms`."""
11
14
  return now_ms + round(expires_in_seconds * MS_PER_SECOND)
12
15
 
13
16
 
14
17
  @dataclass(frozen=True)
15
18
  class AccessToken:
19
+ """A bearer token and when it expires.
20
+
21
+ Attributes:
22
+ value: The token; never shown in `repr`.
23
+ expires_at_ms: Expiry in epoch milliseconds.
24
+ """
25
+
16
26
  value: str = field(repr=False)
17
27
  expires_at_ms: int
18
28
 
@@ -1,3 +1,5 @@
1
+ """Machine-client authentication with the OAuth client-credentials grant."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from ovok_sdk.auth.access_token import AccessToken, expires_at_for
@@ -10,7 +12,13 @@ CLIENT_CREDENTIALS_GRANT = "client_credentials"
10
12
 
11
13
 
12
14
  class ClientCredentials:
13
- """A machine client's own token, fetched from the OAuth endpoint and renewed before it expires."""
15
+ """A machine client's own token, fetched from the OAuth endpoint and renewed before it expires.
16
+
17
+ Args:
18
+ endpoint: The OAuth token endpoint.
19
+ client: The machine client's credentials.
20
+ clock: Current time in epoch milliseconds.
21
+ """
14
22
 
15
23
  def __init__(self, endpoint: TokenEndpoint, client: MachineClient, clock: Clock = epoch_ms) -> None:
16
24
  self._endpoint = endpoint
@@ -20,12 +28,18 @@ class ClientCredentials:
20
28
  self._flight: SingleFlight[AccessToken] = SingleFlight()
21
29
 
22
30
  def access_token(self) -> str:
31
+ """Return a fresh access token, requesting a new one when the current one is stale."""
23
32
  token = self._token
24
33
  if token is None or token.is_stale_at(self._clock()):
25
34
  token = self._flight.run(self._request_token)
26
35
  return token.value
27
36
 
28
37
  def refresh(self) -> bool:
38
+ """Request a new token.
39
+
40
+ Returns:
41
+ Always True; a failed request raises instead.
42
+ """
29
43
  self._flight.run(self._request_token)
30
44
  return True
31
45
 
@@ -0,0 +1,28 @@
1
+ """The interface that supplies bearer tokens to requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+
8
+ class Credentials(Protocol):
9
+ """Supplies the bearer token for each request and renews it on demand."""
10
+
11
+ def access_token(self) -> str | None:
12
+ """Return the current access token, or None when there is none."""
13
+ ...
14
+
15
+ def refresh(self) -> bool:
16
+ """Renew the token; return whether a new one is available."""
17
+ ...
18
+
19
+
20
+ class _NoCredentials:
21
+ def access_token(self) -> None:
22
+ return None
23
+
24
+ def refresh(self) -> bool:
25
+ return False
26
+
27
+
28
+ NO_CREDENTIALS: Credentials = _NoCredentials()
@@ -1,3 +1,5 @@
1
+ """PKCE (RFC 7636) verifier and challenge generation."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import base64
@@ -14,15 +16,24 @@ BASE64_PADDING = "="
14
16
 
15
17
  @dataclass(frozen=True)
16
18
  class PkcePair:
19
+ """A PKCE code verifier and its S256 challenge.
20
+
21
+ Attributes:
22
+ code_verifier: Random secret; never shown in `repr`.
23
+ code_challenge: Base64url SHA-256 of the verifier, without padding.
24
+ """
25
+
17
26
  code_verifier: str = field(repr=False)
18
27
  code_challenge: str
19
28
 
20
29
 
21
30
  def generate_pkce_pair() -> PkcePair:
31
+ """Return a new random verifier of 128 unreserved characters and its challenge."""
22
32
  verifier = "".join(secrets.choice(UNRESERVED_CHARACTERS) for _ in range(VERIFIER_LENGTH))
23
33
  return PkcePair(verifier, code_challenge_for(verifier))
24
34
 
25
35
 
26
36
  def code_challenge_for(verifier: str) -> str:
37
+ """Return the S256 code challenge for `verifier`."""
27
38
  digest = hashlib.sha256(verifier.encode(ASCII)).digest()
28
39
  return base64.urlsafe_b64encode(digest).decode(ASCII).rstrip(BASE64_PADDING)
@@ -1,3 +1,5 @@
1
+ """The signed-in user's session and its JSON conversions."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from collections.abc import Mapping
@@ -10,12 +12,29 @@ from ovok_sdk.auth.token_endpoint import TokenResponse
10
12
 
11
13
  @dataclass(frozen=True)
12
14
  class OvokReference:
15
+ """A FHIR reference to a resource.
16
+
17
+ Attributes:
18
+ reference: Relative reference, such as `Patient/123`.
19
+ display: Human-readable label, when the API sends one.
20
+ """
21
+
13
22
  reference: str
14
23
  display: str | None = None
15
24
 
16
25
 
17
26
  @dataclass(frozen=True)
18
27
  class Session:
28
+ """The signed-in user's tokens, project and profile.
29
+
30
+ Attributes:
31
+ access_token: Bearer token; never shown in `repr`.
32
+ refresh_token: Token that renews the session, if any; never shown in `repr`.
33
+ expires_at_ms: Access-token expiry in epoch milliseconds.
34
+ project: The project the user signed in to.
35
+ profile: The user's profile resource, such as a Patient or Practitioner.
36
+ """
37
+
19
38
  access_token: str = field(repr=False)
20
39
  refresh_token: str | None = field(repr=False)
21
40
  expires_at_ms: int
@@ -23,11 +42,20 @@ class Session:
23
42
  profile: OvokReference
24
43
 
25
44
  def is_stale_at(self, now_ms: int) -> bool:
45
+ """Whether the access token is within a minute of expiring at `now_ms`."""
26
46
  return AccessToken(self.access_token, self.expires_at_ms).is_stale_at(now_ms)
27
47
 
28
48
 
29
49
  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."""
50
+ """Build a session from a sign-in response.
51
+
52
+ Args:
53
+ tokens: The camelCase body every Ovok sign-in route answers with.
54
+ now_ms: Current time in epoch milliseconds.
55
+
56
+ Returns:
57
+ The new session.
58
+ """
31
59
  return Session(
32
60
  access_token=tokens["accessToken"],
33
61
  refresh_token=tokens.get("refreshToken"),
@@ -38,6 +66,7 @@ def session_from_login(tokens: Mapping[str, Any], now_ms: int) -> Session:
38
66
 
39
67
 
40
68
  def session_after_refresh(session: Session, response: TokenResponse, now_ms: int) -> Session:
69
+ """Return `session` with the tokens from a refresh, keeping the old refresh token if none came back."""
41
70
  return replace(
42
71
  session,
43
72
  access_token=response.access_token,
@@ -47,8 +76,10 @@ def session_after_refresh(session: Session, response: TokenResponse, now_ms: int
47
76
 
48
77
 
49
78
  def reference_from_json(value: Mapping[str, Any]) -> OvokReference:
79
+ """Read a reference from its FHIR JSON shape."""
50
80
  return OvokReference(value["reference"], value.get("display"))
51
81
 
52
82
 
53
83
  def reference_to_json(reference: OvokReference) -> dict[str, Any]:
84
+ """Write a reference in its FHIR JSON shape."""
54
85
  return {"reference": reference.reference, "display": reference.display}
@@ -1,3 +1,5 @@
1
+ """Persistence of the user session in `OvokStorage`."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import json
@@ -11,7 +13,12 @@ SESSION_KEY = "ovok:{tenant_code}:session"
11
13
 
12
14
 
13
15
  class SessionStore:
14
- """Keeps the session as JSON in the TypeScript SDK's shape, so stores are interchangeable."""
16
+ """Keeps the session as JSON in the TypeScript SDK's shape, so stores are interchangeable.
17
+
18
+ Args:
19
+ storage: Where the session is kept.
20
+ key: Storage key of the session.
21
+ """
15
22
 
16
23
  def __init__(self, storage: OvokStorage, key: str) -> None:
17
24
  self._storage = storage
@@ -23,13 +30,16 @@ class SessionStore:
23
30
  return cls(storage, SESSION_KEY.format(tenant_code=tenant_code))
24
31
 
25
32
  def load(self) -> Session | None:
33
+ """Return the stored session, or None when none is stored or it is unreadable."""
26
34
  raw = self._storage.get_item(self._key)
27
35
  return None if raw is None else _session_from_json(raw)
28
36
 
29
37
  def save(self, session: Session) -> None:
38
+ """Store `session`, replacing any earlier one."""
30
39
  self._storage.set_item(self._key, _session_to_json(session))
31
40
 
32
41
  def clear(self) -> None:
42
+ """Delete the stored session."""
33
43
  self._storage.remove_item(self._key)
34
44
 
35
45
 
@@ -1,3 +1,5 @@
1
+ """Deduplication of concurrent calls, such as token refreshes."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import threading
@@ -19,6 +21,14 @@ class SingleFlight(Generic[T]):
19
21
  self._latest: Settled[T] = _nothing_ran
20
22
 
21
23
  def run(self, call: Callable[[], T]) -> T:
24
+ """Run `call`, or wait for the run in progress and share its outcome.
25
+
26
+ Returns:
27
+ The call's result.
28
+
29
+ Raises:
30
+ Exception: Whatever the shared call raised.
31
+ """
22
32
  calls_seen = self._finished_calls
23
33
  with self._lock:
24
34
  if self._finished_calls == calls_seen:
@@ -1,3 +1,5 @@
1
+ """The OAuth token endpoint."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from collections.abc import Mapping
@@ -15,22 +17,46 @@ FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "
15
17
 
16
18
  @dataclass(frozen=True)
17
19
  class TokenResponse:
20
+ """A successful token endpoint response.
21
+
22
+ Attributes:
23
+ access_token: The new bearer token; never shown in `repr`.
24
+ expires_in: Lifetime of the access token in seconds.
25
+ refresh_token: A new refresh token, when the endpoint issues one.
26
+ """
27
+
18
28
  access_token: str = field(repr=False)
19
29
  expires_in: float
20
30
  refresh_token: str | None = field(default=None, repr=False)
21
31
 
22
32
  @classmethod
23
33
  def from_json(cls, body: Mapping[str, Any]) -> TokenResponse:
34
+ """Read the snake_case OAuth response body."""
24
35
  return cls(body["access_token"], body["expires_in"], body.get("refresh_token"))
25
36
 
26
37
 
27
38
  class TokenEndpoint:
28
- """Ovok's OAuth token endpoint: one plain form POST, without retries or credentials."""
39
+ """Ovok's OAuth token endpoint: one plain form POST, without retries or credentials.
40
+
41
+ Args:
42
+ settings: The client's settings.
43
+ """
29
44
 
30
45
  def __init__(self, settings: OvokSettings) -> None:
31
46
  self._settings = settings
32
47
 
33
48
  def request(self, form: Mapping[str, str]) -> TokenResponse:
49
+ """Post `form` to the token endpoint.
50
+
51
+ Args:
52
+ form: OAuth form fields, such as `grant_type`.
53
+
54
+ Returns:
55
+ The issued tokens.
56
+
57
+ Raises:
58
+ OvokError: The endpoint answered with an error.
59
+ """
34
60
  response = self._settings.transport(
35
61
  OvokRequest(
36
62
  method="POST",
@@ -1,3 +1,5 @@
1
+ """The signed-in user's session and its token refresh."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from collections.abc import Callable, Mapping
@@ -16,7 +18,14 @@ REJECTED_REFRESH_STATUSES = frozenset({400, 401, 403})
16
18
 
17
19
 
18
20
  class UserSession:
19
- """The signed-in user's tokens, kept in storage and refreshed a minute before they expire."""
21
+ """The signed-in user's tokens, kept in storage and refreshed a minute before they expire.
22
+
23
+ Args:
24
+ store: Where the session is kept.
25
+ endpoint: The OAuth token endpoint used to refresh.
26
+ settings: The client's settings.
27
+ clock: Current time in epoch milliseconds.
28
+ """
20
29
 
21
30
  def __init__(
22
31
  self,
@@ -33,28 +42,51 @@ class UserSession:
33
42
  self._listeners: list[Callable[[], None]] = []
34
43
 
35
44
  def current(self) -> Session | None:
45
+ """Return the stored session, or None when no one is signed in."""
36
46
  return self._store.load()
37
47
 
38
48
  def begin(self, tokens: Mapping[str, Any]) -> Session:
49
+ """Start a session from a sign-in response and notify listeners.
50
+
51
+ Args:
52
+ tokens: The camelCase body of an Ovok sign-in route.
53
+
54
+ Returns:
55
+ The new session.
56
+ """
39
57
  session = session_from_login(tokens, self._clock())
40
58
  self._store.save(session)
41
59
  self._notify_change()
42
60
  return session
43
61
 
44
62
  def end(self) -> None:
63
+ """Delete the session and notify listeners."""
45
64
  self._store.clear()
46
65
  self._notify_change()
47
66
 
48
67
  def on_change(self, listener: Callable[[], None]) -> None:
68
+ """Call `listener` whenever the session starts, refreshes or ends."""
49
69
  self._listeners.append(listener)
50
70
 
51
71
  def access_token(self) -> str | None:
72
+ """Return the access token, refreshing it first when it is stale; None when signed out."""
52
73
  if self._needs_refresh(self.current()):
53
74
  self.refresh()
54
75
  session = self.current()
55
76
  return None if session is None else session.access_token
56
77
 
57
78
  def refresh(self) -> bool:
79
+ """Exchange the refresh token for new tokens.
80
+
81
+ A refused refresh signs the user out and calls `on_unauthenticated`.
82
+
83
+ Returns:
84
+ True when the session was refreshed; False when there is nothing to refresh
85
+ or the refresh was refused.
86
+
87
+ Raises:
88
+ OvokError: The refresh failed for another reason, such as an outage.
89
+ """
58
90
  return self._flight.run(self._exchange_refresh_token)
59
91
 
60
92
  def _needs_refresh(self, session: Session | None) -> bool:
@@ -1,3 +1,5 @@
1
+ """Wall-clock time in epoch milliseconds."""
2
+
1
3
  import time
2
4
  from collections.abc import Callable
3
5
 
@@ -8,4 +10,5 @@ Clock = Callable[[], int]
8
10
 
9
11
 
10
12
  def epoch_ms() -> int:
13
+ """Return the current time in milliseconds since the Unix epoch."""
11
14
  return time.time_ns() // NS_PER_MS
@@ -1,3 +1,5 @@
1
+ """Client options, checked once when the client is constructed."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import re
@@ -19,12 +21,26 @@ REFRESH_REJECTED = "refresh-rejected"
19
21
 
20
22
  @dataclass(frozen=True)
21
23
  class MachineClient:
24
+ """A machine client's OAuth credentials.
25
+
26
+ Attributes:
27
+ client_id: The client's ID.
28
+ client_secret: The client's secret; never shown in `repr`.
29
+ """
30
+
22
31
  client_id: str
23
32
  client_secret: str = field(repr=False)
24
33
 
25
34
 
26
35
  @dataclass(frozen=True)
27
36
  class UnauthenticatedReason:
37
+ """Why the user was signed out; passed to the `on_unauthenticated` callback.
38
+
39
+ Attributes:
40
+ status: HTTP status of the refused token refresh.
41
+ type: Kind of sign-out; always `"refresh-rejected"` today.
42
+ """
43
+
28
44
  status: int
29
45
  type: str = REFRESH_REJECTED
30
46
 
@@ -34,6 +50,21 @@ OnUnauthenticated = Callable[[UnauthenticatedReason], None]
34
50
 
35
51
  @dataclass(frozen=True)
36
52
  class OvokSettings:
53
+ """The client's checked options; see `Ovok` for what each one means.
54
+
55
+ Attributes:
56
+ base_url: API base URL, ending in `/`.
57
+ tenant_code: Tenant every request is scoped to.
58
+ fhir_path: Path of the FHIR API under `base_url`, without slashes at either end.
59
+ storage: Where the session is kept.
60
+ transport: Sends the HTTP requests.
61
+ timeout: Request timeout in seconds.
62
+ max_network_retries: Retries for safe requests that fail on the network.
63
+ client_id: App client ID, if one was given.
64
+ machine_client: Machine credentials, when a client secret was given.
65
+ on_unauthenticated: Called when the user is signed out.
66
+ """
67
+
37
68
  base_url: str
38
69
  tenant_code: str
39
70
  fhir_path: str
@@ -59,7 +90,16 @@ def read_settings(
59
90
  max_network_retries: int = DEFAULT_MAX_NETWORK_RETRIES,
60
91
  on_unauthenticated: OnUnauthenticated | None = None,
61
92
  ) -> OvokSettings:
62
- """Checks every option up front, so a bad one fails at construction, not on the first request."""
93
+ """Checks every option up front, so a bad one fails at construction, not on the first request.
94
+
95
+ Takes the same arguments as `Ovok`.
96
+
97
+ Returns:
98
+ The checked settings, with defaults filled in.
99
+
100
+ Raises:
101
+ OvokConfigError: An option is missing or invalid.
102
+ """
63
103
  return OvokSettings(
64
104
  base_url=_read_base_url(base_url),
65
105
  tenant_code=_require_text(tenant_code, "tenant_code"),