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.
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/PKG-INFO +8 -3
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/README.md +7 -2
- ovok_sdk-0.1.0.dev19/pyproject.toml +58 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/pyproject.toml.orig +26 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/__init__.py +2 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/auth/__init__.py +1 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/access_token.py +10 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/client_credentials.py +15 -1
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/auth/credentials.py +28 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/pkce.py +11 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/session.py +32 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/session_store.py +11 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/single_flight.py +10 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/token_endpoint.py +27 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/auth/user_session.py +33 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/clock.py +3 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/config.py +41 -1
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/errors.py +142 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/http/__init__.py +1 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/client.py +31 -2
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/error_response.py +11 -1
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/http/request.py +26 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/retry.py +17 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/transport.py +48 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/http/url.py +14 -2
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/ovok.py +27 -1
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/paging.py +24 -1
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/__init__.py +1 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/ai.py +139 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/auth.py +156 -7
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/auth_sessions.py +18 -2
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/bots.py +16 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/cms.py +105 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/consent.py +51 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/content.py +143 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/documents.py +108 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/fhir.py +93 -3
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/health.py +33 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/invites.py +41 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/measurement_builder.py +20 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/measurements.py +148 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/patient.py +79 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/platform.py +348 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/questionnaires.py +45 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/registration.py +47 -2
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/sharing.py +62 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/signals.py +52 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/resources/slim.py +625 -1
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/video_calls.py +170 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/resources/wearables.py +132 -0
- ovok_sdk-0.1.0.dev19/src/ovok_sdk/storage.py +40 -0
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/src/ovok_sdk/values.py +2 -0
- ovok_sdk-0.1.0.dev13/pyproject.toml +0 -21
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/auth/__init__.py +0 -0
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/auth/credentials.py +0 -20
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/errors.py +0 -96
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/http/__init__.py +0 -0
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/http/request.py +0 -16
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/__init__.py +0 -0
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/ai.py +0 -55
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/content.py +0 -58
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/documents.py +0 -41
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/health.py +0 -16
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/platform.py +0 -141
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/video_calls.py +0 -63
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/resources/wearables.py +0 -52
- ovok_sdk-0.1.0.dev13/src/ovok_sdk/storage.py +0 -25
- {ovok_sdk-0.1.0.dev13 → ovok_sdk-0.1.0.dev19}/LICENSE +0 -0
- {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.
|
|
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
|
|
259
|
-
|
|
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
|
|
246
|
-
|
|
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.
|
|
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"
|
|
@@ -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
|
-
"""
|
|
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"),
|