volcano-sdk-python 0.7.3__py3-none-any.whl → 0.9.0__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.
- volcano_sdk/_lock_guard.py +4 -1
- volcano_sdk/_session.py +70 -0
- volcano_sdk/_session_operations.py +158 -0
- volcano_sdk/_transport.py +24 -8
- volcano_sdk/auth.py +163 -69
- volcano_sdk/client.py +55 -10
- volcano_sdk/locks.py +87 -20
- volcano_sdk/models.py +3 -3
- volcano_sdk/realtime.py +11 -7
- volcano_sdk/storage.py +7 -2
- {volcano_sdk_python-0.7.3.dist-info → volcano_sdk_python-0.9.0.dist-info}/METADATA +22 -3
- {volcano_sdk_python-0.7.3.dist-info → volcano_sdk_python-0.9.0.dist-info}/RECORD +14 -12
- {volcano_sdk_python-0.7.3.dist-info → volcano_sdk_python-0.9.0.dist-info}/WHEEL +0 -0
- {volcano_sdk_python-0.7.3.dist-info → volcano_sdk_python-0.9.0.dist-info}/licenses/LICENSE +0 -0
volcano_sdk/_lock_guard.py
CHANGED
|
@@ -54,13 +54,16 @@ class LockGuard:
|
|
|
54
54
|
*,
|
|
55
55
|
ttl: int,
|
|
56
56
|
started_at: float,
|
|
57
|
+
lease_started_at: float | None = None,
|
|
57
58
|
) -> None:
|
|
58
59
|
"""Track one acquired lease against its local monotonic deadline."""
|
|
59
60
|
self._state_lock = threading.Lock()
|
|
60
61
|
self._lease = lease
|
|
61
62
|
self._ttl = ttl
|
|
62
63
|
self._absolute_deadline = started_at + MAX_LOCK_LIFETIME_SECONDS
|
|
63
|
-
|
|
64
|
+
# A successful retry renews the TTL, not the maximum ownership lifetime.
|
|
65
|
+
lease_started_at = started_at if lease_started_at is None else lease_started_at
|
|
66
|
+
self._lease_deadline = min(lease_started_at + ttl, self._absolute_deadline)
|
|
64
67
|
self._failure: Exception | None = None
|
|
65
68
|
self._lost = threading.Event()
|
|
66
69
|
|
volcano_sdk/_session.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Internal session continuity checks shared by refresh and revocation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import TYPE_CHECKING, cast
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
from .errors import AuthenticationError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .models import Session
|
|
15
|
+
|
|
16
|
+
_JWT_PARTS = 3
|
|
17
|
+
_REFRESH_USER_MISMATCH = "Refreshed session belongs to a different user"
|
|
18
|
+
_REFRESH_SESSION_MISMATCH = "Refreshed credentials belong to a different server session"
|
|
19
|
+
_MISSING_SESSION_ID = "Cannot refresh supplied credentials without a session identifier"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def session_id_from_access_token(access_token: str) -> str | None:
|
|
23
|
+
"""Read an untrusted continuity constraint; this never authenticates a user."""
|
|
24
|
+
parts = access_token.split(".")
|
|
25
|
+
if len(parts) != _JWT_PARTS:
|
|
26
|
+
return None
|
|
27
|
+
padding = "=" * (-len(parts[1]) % 4)
|
|
28
|
+
try:
|
|
29
|
+
payload: object = json.loads(
|
|
30
|
+
base64.urlsafe_b64decode(parts[1] + padding).decode()
|
|
31
|
+
)
|
|
32
|
+
except (ValueError, UnicodeDecodeError, RecursionError):
|
|
33
|
+
return None
|
|
34
|
+
if not isinstance(payload, Mapping):
|
|
35
|
+
return None
|
|
36
|
+
values = cast("Mapping[object, object]", payload)
|
|
37
|
+
session_id = values.get("session_id")
|
|
38
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
return str(UUID(session_id.strip()))
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_refresh_source(current: Session, *, verified: bool = False) -> None:
|
|
47
|
+
"""Require a session constraint for supplied credentials."""
|
|
48
|
+
if not verified and session_id_from_access_token(current.access_token) is None:
|
|
49
|
+
raise AuthenticationError(_MISSING_SESSION_ID)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_refresh_identity(current: Session | None, refreshed: Session) -> None:
|
|
53
|
+
"""Reject a refresh outside the captured server session or validated user."""
|
|
54
|
+
if current is None:
|
|
55
|
+
return
|
|
56
|
+
session_id = session_id_from_access_token(current.access_token)
|
|
57
|
+
if session_id is not None and session_id != session_id_from_access_token(
|
|
58
|
+
refreshed.access_token
|
|
59
|
+
):
|
|
60
|
+
raise AuthenticationError(_REFRESH_SESSION_MISMATCH)
|
|
61
|
+
if current.user_id is None:
|
|
62
|
+
return
|
|
63
|
+
if current.user_id == refreshed.user_id:
|
|
64
|
+
return
|
|
65
|
+
try:
|
|
66
|
+
matches = UUID(current.user_id) == UUID(str(refreshed.user_id))
|
|
67
|
+
except ValueError:
|
|
68
|
+
matches = False
|
|
69
|
+
if not matches:
|
|
70
|
+
raise AuthenticationError(_REFRESH_USER_MISMATCH)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Bound refresh and revocation work to one explicitly established session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import Future
|
|
6
|
+
from threading import Lock
|
|
7
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
8
|
+
|
|
9
|
+
from .errors import SessionChangedError, VolcanoError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from .models import Session
|
|
15
|
+
|
|
16
|
+
_T = TypeVar("_T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _copy_failure(error: BaseException, *, include_cause: bool = True) -> BaseException:
|
|
20
|
+
"""Copy public failure details without retaining request frames or contexts."""
|
|
21
|
+
copied = type(error).__new__(type(error), *error.args)
|
|
22
|
+
if isinstance(error, VolcanoError) and isinstance(copied, VolcanoError):
|
|
23
|
+
copied.status = error.status
|
|
24
|
+
copied.code = error.code
|
|
25
|
+
copied.retry_after = error.retry_after
|
|
26
|
+
if include_cause and isinstance(error.__cause__, VolcanoError):
|
|
27
|
+
copied.__cause__ = _copy_failure(error.__cause__, include_cause=False)
|
|
28
|
+
return copied
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionOperations:
|
|
32
|
+
"""Retain only this session's latest refresh and shared sign-out outcome."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, verified: Session | None = None) -> None:
|
|
35
|
+
self._lock = Lock()
|
|
36
|
+
self.refreshing: Future[Session] | None = None
|
|
37
|
+
self.signing_out: Future[BaseException | None] | None = None
|
|
38
|
+
self._locally_cleared = False
|
|
39
|
+
self._verified_pair = (
|
|
40
|
+
(verified.access_token, verified.refresh_token)
|
|
41
|
+
if verified is not None
|
|
42
|
+
else None
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def verify_pair(self, session: Session | None) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
if self._locally_cleared:
|
|
48
|
+
return
|
|
49
|
+
self._verified_pair = (
|
|
50
|
+
(session.access_token, session.refresh_token)
|
|
51
|
+
if session is not None
|
|
52
|
+
else None
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def clear_local_credentials(self) -> None:
|
|
56
|
+
"""Keep pending revocation joinable; discard credentials after other clears."""
|
|
57
|
+
with self._lock:
|
|
58
|
+
if self.signing_out is not None:
|
|
59
|
+
return
|
|
60
|
+
self._locally_cleared = True
|
|
61
|
+
self._verified_pair = None
|
|
62
|
+
refreshing = self.refreshing
|
|
63
|
+
if refreshing is not None:
|
|
64
|
+
refreshing.add_done_callback(self._forget_refresh)
|
|
65
|
+
|
|
66
|
+
def _forget_refresh(self, refreshing: Future[Session]) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
if self.refreshing is refreshing:
|
|
69
|
+
self.refreshing = None
|
|
70
|
+
|
|
71
|
+
def has_verified_pair(self, session: Session) -> bool:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return session.refresh_token is not None and self._verified_pair == (
|
|
74
|
+
session.access_token,
|
|
75
|
+
session.refresh_token,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def refresh(self, operation: Callable[[], Session]) -> Session:
|
|
79
|
+
with self._lock:
|
|
80
|
+
if self.signing_out is not None or self._locally_cleared:
|
|
81
|
+
raise SessionChangedError
|
|
82
|
+
future = self.refreshing
|
|
83
|
+
if future is None or future.done():
|
|
84
|
+
future = self.refreshing = Future()
|
|
85
|
+
owner = True
|
|
86
|
+
else:
|
|
87
|
+
owner = False
|
|
88
|
+
if owner:
|
|
89
|
+
self._complete(future, operation)
|
|
90
|
+
return future.result()
|
|
91
|
+
|
|
92
|
+
def sign_out(
|
|
93
|
+
self, operation: Callable[[Future[Session] | None, bool], None]
|
|
94
|
+
) -> None:
|
|
95
|
+
with self._lock:
|
|
96
|
+
future = self.signing_out
|
|
97
|
+
if future is None:
|
|
98
|
+
future = self.signing_out = Future()
|
|
99
|
+
owner = True
|
|
100
|
+
else:
|
|
101
|
+
owner = False
|
|
102
|
+
preceding = self.refreshing
|
|
103
|
+
pending = preceding is not None and not preceding.done()
|
|
104
|
+
if owner:
|
|
105
|
+
self._complete_revocation(future, operation, preceding, pending=pending)
|
|
106
|
+
self._sign_out_result(future)
|
|
107
|
+
|
|
108
|
+
def wait_for_sign_out(self) -> None:
|
|
109
|
+
with self._lock:
|
|
110
|
+
future = self.signing_out
|
|
111
|
+
pending = future is not None and not future.done()
|
|
112
|
+
if pending and future is not None:
|
|
113
|
+
self._sign_out_result(future)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _sign_out_result(future: Future[BaseException | None]) -> None:
|
|
117
|
+
failure = future.result()
|
|
118
|
+
if failure is not None:
|
|
119
|
+
# Raising the retained template would attach credential-bearing frames.
|
|
120
|
+
raise _copy_failure(failure)
|
|
121
|
+
|
|
122
|
+
def _complete_revocation(
|
|
123
|
+
self,
|
|
124
|
+
future: Future[BaseException | None],
|
|
125
|
+
operation: Callable[[Future[Session] | None, bool], None],
|
|
126
|
+
preceding: Future[Session] | None,
|
|
127
|
+
*,
|
|
128
|
+
pending: bool,
|
|
129
|
+
) -> None:
|
|
130
|
+
try:
|
|
131
|
+
self._revoke(operation, preceding, pending=pending)
|
|
132
|
+
except BaseException as error:
|
|
133
|
+
future.set_result(_copy_failure(error))
|
|
134
|
+
raise
|
|
135
|
+
future.set_result(None)
|
|
136
|
+
|
|
137
|
+
def _revoke(
|
|
138
|
+
self,
|
|
139
|
+
operation: Callable[[Future[Session] | None, bool], None],
|
|
140
|
+
preceding: Future[Session] | None,
|
|
141
|
+
*,
|
|
142
|
+
pending: bool,
|
|
143
|
+
) -> None:
|
|
144
|
+
try:
|
|
145
|
+
operation(preceding, pending)
|
|
146
|
+
finally:
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._verified_pair = None
|
|
149
|
+
self.refreshing = None
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _complete(future: Future[_T], operation: Callable[[], _T]) -> None:
|
|
153
|
+
try:
|
|
154
|
+
future.set_result(operation())
|
|
155
|
+
except BaseException as error:
|
|
156
|
+
# Every waiter must finish, including when the owner is interrupted.
|
|
157
|
+
future.set_exception(error)
|
|
158
|
+
raise
|
volcano_sdk/_transport.py
CHANGED
|
@@ -104,12 +104,17 @@ from ._generated.api.functions.invoke_function import (
|
|
|
104
104
|
_get_kwargs as invoke_function_kwargs,
|
|
105
105
|
)
|
|
106
106
|
from ._generated.api.locks import (
|
|
107
|
-
acquire_project_lock,
|
|
108
107
|
force_release_project_lock,
|
|
109
108
|
get_project_lock,
|
|
110
109
|
release_project_lock,
|
|
111
110
|
renew_project_lock,
|
|
112
111
|
)
|
|
112
|
+
from ._generated.api.locks.acquire_project_lock import (
|
|
113
|
+
_build_response as build_lock_acquire_response,
|
|
114
|
+
)
|
|
115
|
+
from ._generated.api.locks.acquire_project_lock import (
|
|
116
|
+
_get_kwargs as lock_acquire_kwargs,
|
|
117
|
+
)
|
|
113
118
|
from ._generated.api.logs.get_project_log_activity import (
|
|
114
119
|
_build_response as build_log_activity_response,
|
|
115
120
|
)
|
|
@@ -253,6 +258,7 @@ if TYPE_CHECKING:
|
|
|
253
258
|
)
|
|
254
259
|
from .models import JSONValue
|
|
255
260
|
|
|
261
|
+
HTTP_CREATED = 201
|
|
256
262
|
HTTP_UNAUTHORIZED = 401
|
|
257
263
|
HTTP_NOT_FOUND = 404
|
|
258
264
|
HTTP_CONFLICT = 409
|
|
@@ -643,6 +649,7 @@ class Transport(Protocol):
|
|
|
643
649
|
key: str,
|
|
644
650
|
ttl: int,
|
|
645
651
|
token: str,
|
|
652
|
+
request_id: str | None = None,
|
|
646
653
|
) -> TransportResponse: ...
|
|
647
654
|
|
|
648
655
|
def release_project_lock(
|
|
@@ -651,6 +658,7 @@ class Transport(Protocol):
|
|
|
651
658
|
authorization: str,
|
|
652
659
|
key: str,
|
|
653
660
|
token: str,
|
|
661
|
+
request_id: str | None = None,
|
|
654
662
|
) -> TransportResponse: ...
|
|
655
663
|
|
|
656
664
|
|
|
@@ -1735,15 +1743,19 @@ class GeneratedTransport:
|
|
|
1735
1743
|
key: str,
|
|
1736
1744
|
ttl: int,
|
|
1737
1745
|
token: str,
|
|
1746
|
+
request_id: str | None = None,
|
|
1738
1747
|
) -> TransportResponse:
|
|
1739
1748
|
with self._client(authorization) as client:
|
|
1740
|
-
|
|
1749
|
+
request_kwargs = lock_acquire_kwargs(
|
|
1741
1750
|
key,
|
|
1742
|
-
client=client,
|
|
1743
1751
|
body=ProjectLockLeaseRequest(ttl_seconds=ttl),
|
|
1744
1752
|
x_volcano_lock_token=cast("UUID", token),
|
|
1745
|
-
x_volcano_request_id=cast("UUID", str(uuid4())),
|
|
1753
|
+
x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
|
|
1746
1754
|
)
|
|
1755
|
+
raw_response = client.get_httpx_client().request(**request_kwargs)
|
|
1756
|
+
if raw_response.status_code != HTTP_CREATED:
|
|
1757
|
+
return self._raw_response(raw_response)
|
|
1758
|
+
response = build_lock_acquire_response(client=client, response=raw_response)
|
|
1747
1759
|
return self._response(response)
|
|
1748
1760
|
|
|
1749
1761
|
def get_project_lock(
|
|
@@ -1751,12 +1763,13 @@ class GeneratedTransport:
|
|
|
1751
1763
|
*,
|
|
1752
1764
|
authorization: str,
|
|
1753
1765
|
key: str,
|
|
1766
|
+
request_id: str | None = None,
|
|
1754
1767
|
) -> TransportResponse:
|
|
1755
1768
|
with self._client(authorization) as client:
|
|
1756
1769
|
response = get_project_lock.sync_detailed(
|
|
1757
1770
|
key,
|
|
1758
1771
|
client=client,
|
|
1759
|
-
x_volcano_request_id=cast("UUID", str(uuid4())),
|
|
1772
|
+
x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
|
|
1760
1773
|
)
|
|
1761
1774
|
return self._response(response)
|
|
1762
1775
|
|
|
@@ -1765,12 +1778,13 @@ class GeneratedTransport:
|
|
|
1765
1778
|
*,
|
|
1766
1779
|
authorization: str,
|
|
1767
1780
|
key: str,
|
|
1781
|
+
request_id: str | None = None,
|
|
1768
1782
|
) -> TransportResponse:
|
|
1769
1783
|
with self._client(authorization) as client:
|
|
1770
1784
|
response = force_release_project_lock.sync_detailed(
|
|
1771
1785
|
key,
|
|
1772
1786
|
client=client,
|
|
1773
|
-
x_volcano_request_id=cast("UUID", str(uuid4())),
|
|
1787
|
+
x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
|
|
1774
1788
|
)
|
|
1775
1789
|
return self._response(response)
|
|
1776
1790
|
|
|
@@ -1781,6 +1795,7 @@ class GeneratedTransport:
|
|
|
1781
1795
|
key: str,
|
|
1782
1796
|
ttl: int,
|
|
1783
1797
|
token: str,
|
|
1798
|
+
request_id: str | None = None,
|
|
1784
1799
|
) -> TransportResponse:
|
|
1785
1800
|
with self._client(authorization) as client:
|
|
1786
1801
|
response = renew_project_lock.sync_detailed(
|
|
@@ -1788,7 +1803,7 @@ class GeneratedTransport:
|
|
|
1788
1803
|
client=client,
|
|
1789
1804
|
body=ProjectLockLeaseRequest(ttl_seconds=ttl),
|
|
1790
1805
|
x_volcano_lock_token=cast("UUID", token),
|
|
1791
|
-
x_volcano_request_id=cast("UUID", str(uuid4())),
|
|
1806
|
+
x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
|
|
1792
1807
|
)
|
|
1793
1808
|
return self._response(response)
|
|
1794
1809
|
|
|
@@ -1798,12 +1813,13 @@ class GeneratedTransport:
|
|
|
1798
1813
|
authorization: str,
|
|
1799
1814
|
key: str,
|
|
1800
1815
|
token: str,
|
|
1816
|
+
request_id: str | None = None,
|
|
1801
1817
|
) -> TransportResponse:
|
|
1802
1818
|
with self._client(authorization) as client:
|
|
1803
1819
|
response = release_project_lock.sync_detailed(
|
|
1804
1820
|
key,
|
|
1805
1821
|
client=client,
|
|
1806
1822
|
x_volcano_lock_token=cast("UUID", token),
|
|
1807
|
-
x_volcano_request_id=cast("UUID", str(uuid4())),
|
|
1823
|
+
x_volcano_request_id=cast("UUID", request_id or str(uuid4())),
|
|
1808
1824
|
)
|
|
1809
1825
|
return self._response(response)
|
volcano_sdk/auth.py
CHANGED
|
@@ -2,11 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import base64
|
|
6
|
-
import binascii
|
|
7
|
-
import json
|
|
8
5
|
import secrets
|
|
9
|
-
import threading
|
|
10
6
|
from collections.abc import Mapping
|
|
11
7
|
from contextlib import suppress
|
|
12
8
|
from copy import deepcopy
|
|
@@ -43,6 +39,11 @@ from ._generated.models.refresh_o_auth_provider_token_response_200 import (
|
|
|
43
39
|
RefreshOAuthProviderTokenResponse200,
|
|
44
40
|
)
|
|
45
41
|
from ._generated.types import Unset
|
|
42
|
+
from ._session import (
|
|
43
|
+
session_id_from_access_token,
|
|
44
|
+
validate_refresh_identity,
|
|
45
|
+
validate_refresh_source,
|
|
46
|
+
)
|
|
46
47
|
from ._transport import (
|
|
47
48
|
AuthCallOAuthAPITransport,
|
|
48
49
|
AuthCancelEmailChangeTransport,
|
|
@@ -75,6 +76,7 @@ from ._transport import (
|
|
|
75
76
|
)
|
|
76
77
|
from .errors import (
|
|
77
78
|
AuthenticationError,
|
|
79
|
+
RateLimitedError,
|
|
78
80
|
SessionChangedError,
|
|
79
81
|
TransportError,
|
|
80
82
|
VolcanoError,
|
|
@@ -115,8 +117,8 @@ _INVALID_OAUTH_PARAMETER = "OAuth parameters must be non-empty strings"
|
|
|
115
117
|
_INVALID_OAUTH_STATE = "OAuth state must not exceed 255 characters"
|
|
116
118
|
_OAUTH_STATE_MISMATCH = "OAuth state mismatch"
|
|
117
119
|
_MAX_OAUTH_STATE_LENGTH = 255
|
|
118
|
-
_JWT_PARTS = 3
|
|
119
120
|
_NO_ACTIVE_SESSION = "No active session"
|
|
121
|
+
_REFRESH_UNAVAILABLE = "No refresh token"
|
|
120
122
|
_T = TypeVar("_T")
|
|
121
123
|
_OAUTH_PROVIDERS: frozenset[str] = frozenset({"apple", "github", "google", "microsoft"})
|
|
122
124
|
_OAUTH_API_METHODS: frozenset[str] = frozenset({"GET", "POST"})
|
|
@@ -124,11 +126,13 @@ _HOSTED_AUTH_ACTIONS: frozenset[str] = frozenset({"login", "signup", "forgot-pas
|
|
|
124
126
|
|
|
125
127
|
if TYPE_CHECKING:
|
|
126
128
|
from collections.abc import Callable
|
|
129
|
+
from concurrent.futures import Future
|
|
127
130
|
|
|
128
131
|
from ._generated.models import (
|
|
129
132
|
AuthListOAuthProvidersResponse200ProvidersItem,
|
|
130
133
|
)
|
|
131
134
|
from ._generated.models.auth_session import AuthSession as GeneratedAuthSession
|
|
135
|
+
from ._session_operations import SessionOperations
|
|
132
136
|
from ._transport import TransportResponse
|
|
133
137
|
|
|
134
138
|
|
|
@@ -169,26 +173,6 @@ def _validate_oauth_callback_state(state: str, expected_state: str) -> None:
|
|
|
169
173
|
raise ValueError(_OAUTH_STATE_MISMATCH)
|
|
170
174
|
|
|
171
175
|
|
|
172
|
-
def _session_id_from_access_token(access_token: str) -> str | None:
|
|
173
|
-
parts = access_token.split(".")
|
|
174
|
-
if len(parts) != _JWT_PARTS:
|
|
175
|
-
return None
|
|
176
|
-
padding = "=" * (-len(parts[1]) % 4)
|
|
177
|
-
try:
|
|
178
|
-
payload: object = json.loads(
|
|
179
|
-
base64.urlsafe_b64decode(parts[1] + padding).decode()
|
|
180
|
-
)
|
|
181
|
-
except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError):
|
|
182
|
-
return None
|
|
183
|
-
if not isinstance(payload, Mapping):
|
|
184
|
-
return None
|
|
185
|
-
values = cast("Mapping[object, object]", payload)
|
|
186
|
-
session_id = values.get("session_id")
|
|
187
|
-
if not isinstance(session_id, str) or not session_id.strip():
|
|
188
|
-
return None
|
|
189
|
-
return session_id.strip()
|
|
190
|
-
|
|
191
|
-
|
|
192
176
|
def _has_complete_values(session: Session) -> bool:
|
|
193
177
|
return all(
|
|
194
178
|
_is_non_empty_string(value)
|
|
@@ -468,7 +452,9 @@ class AuthContext(Protocol):
|
|
|
468
452
|
|
|
469
453
|
def _capture_session(self) -> tuple[int, Session | None]: ...
|
|
470
454
|
|
|
471
|
-
def _capture_session_binding(
|
|
455
|
+
def _capture_session_binding(
|
|
456
|
+
self,
|
|
457
|
+
) -> tuple[int, SessionOperations, Session | None]: ...
|
|
472
458
|
|
|
473
459
|
def _update_session_user_if_current(
|
|
474
460
|
self, user: Mapping[str, JSONValue], generation: int
|
|
@@ -487,6 +473,7 @@ class AuthContext(Protocol):
|
|
|
487
473
|
self,
|
|
488
474
|
generation: int,
|
|
489
475
|
*,
|
|
476
|
+
lineage: SessionOperations | None = None,
|
|
490
477
|
event: AuthChangeEvent = "SIGNED_OUT",
|
|
491
478
|
notifications: list[Callable[[], None]] | None = None,
|
|
492
479
|
) -> bool: ...
|
|
@@ -503,8 +490,7 @@ class Auth:
|
|
|
503
490
|
def __init__(self, client: AuthContext) -> None:
|
|
504
491
|
"""Create an authentication facade backed by a client."""
|
|
505
492
|
self._client = client
|
|
506
|
-
self.
|
|
507
|
-
self._rejected_refresh: tuple[int, int] | None = None
|
|
493
|
+
self._rejected_refresh: tuple[int, SessionOperations] | None = None
|
|
508
494
|
|
|
509
495
|
def get_session(self) -> Session | None:
|
|
510
496
|
"""Return the immutable locally held session without validating it."""
|
|
@@ -895,7 +881,7 @@ class Auth:
|
|
|
895
881
|
generation, current = self._client._capture_session()
|
|
896
882
|
if current is None:
|
|
897
883
|
raise AuthenticationError(_NO_ACTIVE_SESSION)
|
|
898
|
-
current_session_id =
|
|
884
|
+
current_session_id = session_id_from_access_token(current.access_token)
|
|
899
885
|
deletes_current = (
|
|
900
886
|
current_session_id is not None
|
|
901
887
|
and current_session_id.casefold() == session_id.casefold()
|
|
@@ -953,7 +939,7 @@ class Auth:
|
|
|
953
939
|
response_payload(response, 200)
|
|
954
940
|
|
|
955
941
|
def _update_current_user(
|
|
956
|
-
self, payload: object, binding: tuple[int,
|
|
942
|
+
self, payload: object, binding: tuple[int, SessionOperations, Session | None]
|
|
957
943
|
) -> User:
|
|
958
944
|
generation = self._owned_refresh_session(binding)[0]
|
|
959
945
|
user, snapshot = _user_from_payload(payload)
|
|
@@ -1030,7 +1016,7 @@ class Auth:
|
|
|
1030
1016
|
self,
|
|
1031
1017
|
operation: Callable[[str], TransportResponse],
|
|
1032
1018
|
*,
|
|
1033
|
-
binding: tuple[int,
|
|
1019
|
+
binding: tuple[int, SessionOperations, Session | None] | None = None,
|
|
1034
1020
|
) -> TransportResponse:
|
|
1035
1021
|
if binding is None:
|
|
1036
1022
|
binding = self._client._capture_session_binding()
|
|
@@ -1057,24 +1043,31 @@ class Auth:
|
|
|
1057
1043
|
self._owned_refresh_session(binding)
|
|
1058
1044
|
return response
|
|
1059
1045
|
|
|
1060
|
-
def _validate_read_failure(
|
|
1046
|
+
def _validate_read_failure(
|
|
1047
|
+
self, binding: tuple[int, SessionOperations, Session | None]
|
|
1048
|
+
) -> None:
|
|
1061
1049
|
with suppress(AuthenticationError):
|
|
1062
1050
|
self._owned_refresh_session(binding)
|
|
1063
1051
|
|
|
1064
1052
|
def _refresh_session_for_binding(
|
|
1065
|
-
self, binding: tuple[int,
|
|
1053
|
+
self, binding: tuple[int, SessionOperations, Session | None]
|
|
1066
1054
|
) -> Session:
|
|
1067
|
-
generation,
|
|
1055
|
+
generation, owner, current = binding
|
|
1068
1056
|
if current is None:
|
|
1069
1057
|
raise AuthenticationError(_NO_ACTIVE_SESSION)
|
|
1070
1058
|
notifications: list[Callable[[], None]] = []
|
|
1071
1059
|
try:
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
self._perform_refresh(binding, current, notifications)
|
|
1060
|
+
active_generation, _, _ = self._owned_refresh_session(binding)
|
|
1061
|
+
if active_generation == generation:
|
|
1062
|
+
owner.refresh(
|
|
1063
|
+
lambda: self._perform_refresh(binding, current, notifications)
|
|
1064
|
+
)
|
|
1065
|
+
if owner.signing_out is not None:
|
|
1066
|
+
raise SessionChangedError
|
|
1067
|
+
except VolcanoError:
|
|
1068
|
+
self._validate_read_failure(binding)
|
|
1069
|
+
raise
|
|
1076
1070
|
finally:
|
|
1077
|
-
# Only the dispatcher owner drains, after refresh coordination is released.
|
|
1078
1071
|
for dispatch in notifications:
|
|
1079
1072
|
dispatch()
|
|
1080
1073
|
active = self._owned_refresh_session(binding)[2]
|
|
@@ -1083,14 +1076,14 @@ class Auth:
|
|
|
1083
1076
|
return active
|
|
1084
1077
|
|
|
1085
1078
|
def _owned_refresh_session(
|
|
1086
|
-
self, binding: tuple[int,
|
|
1087
|
-
) -> tuple[int,
|
|
1079
|
+
self, binding: tuple[int, SessionOperations, Session | None]
|
|
1080
|
+
) -> tuple[int, SessionOperations, Session | None]:
|
|
1088
1081
|
generation, lineage, _ = binding
|
|
1089
1082
|
active = self._client._capture_session_binding()
|
|
1090
|
-
if
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
None
|
|
1083
|
+
if (
|
|
1084
|
+
self._rejected_refresh == (generation, lineage)
|
|
1085
|
+
and active[0] == generation + 1
|
|
1086
|
+
and active[2] is None
|
|
1094
1087
|
):
|
|
1095
1088
|
raise AuthenticationError(_NO_ACTIVE_SESSION)
|
|
1096
1089
|
if active[1] != lineage or active[2] is None:
|
|
@@ -1099,35 +1092,49 @@ class Auth:
|
|
|
1099
1092
|
|
|
1100
1093
|
def _perform_refresh(
|
|
1101
1094
|
self,
|
|
1102
|
-
binding: tuple[int,
|
|
1095
|
+
binding: tuple[int, SessionOperations, Session | None],
|
|
1103
1096
|
current: Session,
|
|
1104
1097
|
notifications: list[Callable[[], None]],
|
|
1105
|
-
) ->
|
|
1106
|
-
generation,
|
|
1098
|
+
) -> Session:
|
|
1099
|
+
generation, owner, _ = binding
|
|
1100
|
+
active_generation, _, active = self._owned_refresh_session(binding)
|
|
1101
|
+
if active_generation != generation and active is not None:
|
|
1102
|
+
return active
|
|
1103
|
+
if current.refresh_token is None:
|
|
1104
|
+
raise AuthenticationError(_REFRESH_UNAVAILABLE)
|
|
1105
|
+
verified = owner.has_verified_pair(current)
|
|
1106
|
+
validate_refresh_source(current, verified=verified)
|
|
1107
|
+
owner.verify_pair(None)
|
|
1107
1108
|
try:
|
|
1108
|
-
refreshed = self._request_refreshed_session(current)
|
|
1109
|
+
refreshed = self._request_refreshed_session(current.refresh_token)
|
|
1110
|
+
except RateLimitedError:
|
|
1111
|
+
if verified:
|
|
1112
|
+
owner.verify_pair(current)
|
|
1113
|
+
raise
|
|
1109
1114
|
except AuthenticationError:
|
|
1110
|
-
if
|
|
1115
|
+
if owner.signing_out is None and self._client._clear_session_if_current(
|
|
1111
1116
|
generation, notifications=notifications
|
|
1112
1117
|
):
|
|
1113
|
-
|
|
1114
|
-
self._rejected_refresh = (generation, lineage)
|
|
1118
|
+
self._rejected_refresh = (generation, owner)
|
|
1115
1119
|
raise
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1120
|
+
validate_refresh_identity(current, refreshed)
|
|
1121
|
+
owner.verify_pair(refreshed)
|
|
1122
|
+
if owner.signing_out is None:
|
|
1123
|
+
self._client._set_session_if_current(
|
|
1124
|
+
refreshed,
|
|
1125
|
+
generation,
|
|
1126
|
+
event="TOKEN_REFRESHED",
|
|
1127
|
+
notifications=notifications,
|
|
1128
|
+
)
|
|
1129
|
+
return refreshed
|
|
1123
1130
|
|
|
1124
|
-
def _request_refreshed_session(self,
|
|
1131
|
+
def _request_refreshed_session(self, refresh_token: str) -> Session:
|
|
1125
1132
|
transport = cast("AuthRefreshTransport", self._client._transport)
|
|
1126
1133
|
try:
|
|
1127
1134
|
response = invoke(
|
|
1128
1135
|
transport.auth_refresh,
|
|
1129
1136
|
authorization=self._client._anon_token(),
|
|
1130
|
-
refresh_token=
|
|
1137
|
+
refresh_token=refresh_token,
|
|
1131
1138
|
)
|
|
1132
1139
|
return _session_from_payload(response_payload(response, 200))
|
|
1133
1140
|
except (KeyError, TypeError, ValueError) as error:
|
|
@@ -1135,21 +1142,108 @@ class Auth:
|
|
|
1135
1142
|
|
|
1136
1143
|
def sign_out(self) -> None:
|
|
1137
1144
|
"""Revoke and clear the current session."""
|
|
1138
|
-
|
|
1145
|
+
binding = self._client._capture_session_binding()
|
|
1146
|
+
if binding[2] is None:
|
|
1147
|
+
binding[1].wait_for_sign_out()
|
|
1148
|
+
return
|
|
1149
|
+
notifications: list[Callable[[], None]] = []
|
|
1150
|
+
try:
|
|
1151
|
+
binding[1].sign_out(
|
|
1152
|
+
lambda preceding, pending: self._sign_out_captured(
|
|
1153
|
+
binding, preceding, notifications, pending=pending
|
|
1154
|
+
)
|
|
1155
|
+
)
|
|
1156
|
+
finally:
|
|
1157
|
+
for dispatch in notifications:
|
|
1158
|
+
dispatch()
|
|
1159
|
+
|
|
1160
|
+
def _sign_out_captured(
|
|
1161
|
+
self,
|
|
1162
|
+
binding: tuple[int, SessionOperations, Session | None],
|
|
1163
|
+
preceding: Future[Session] | None,
|
|
1164
|
+
notifications: list[Callable[[], None]],
|
|
1165
|
+
*,
|
|
1166
|
+
pending: bool,
|
|
1167
|
+
) -> None:
|
|
1168
|
+
generation, owner, current = binding
|
|
1169
|
+
refresh_error: VolcanoError | None = None
|
|
1170
|
+
if preceding is not None:
|
|
1171
|
+
try:
|
|
1172
|
+
current = preceding.result()
|
|
1173
|
+
except VolcanoError as caught:
|
|
1174
|
+
refresh_error = caught
|
|
1139
1175
|
if current is None:
|
|
1140
1176
|
return
|
|
1141
|
-
transport = cast("AuthLogoutTransport", self._client._transport)
|
|
1142
1177
|
error: VolcanoError | None = None
|
|
1143
1178
|
try:
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
authorization=self._client._anon_token(),
|
|
1147
|
-
refresh_token=current.refresh_token,
|
|
1179
|
+
self._revoke_session(
|
|
1180
|
+
current, owner, refresh_error if pending else None, joined=pending
|
|
1148
1181
|
)
|
|
1149
|
-
response_payload(response, 204)
|
|
1150
1182
|
except VolcanoError as caught:
|
|
1151
1183
|
error = caught
|
|
1152
|
-
if not self._client._clear_session_if_current(
|
|
1184
|
+
if not self._client._clear_session_if_current(
|
|
1185
|
+
generation, lineage=owner, notifications=notifications
|
|
1186
|
+
):
|
|
1153
1187
|
raise SessionChangedError from error
|
|
1154
1188
|
if error is not None:
|
|
1155
1189
|
raise error
|
|
1190
|
+
|
|
1191
|
+
def _revoke_session(
|
|
1192
|
+
self,
|
|
1193
|
+
session: Session,
|
|
1194
|
+
owner: SessionOperations,
|
|
1195
|
+
refresh_error: VolcanoError | None,
|
|
1196
|
+
*,
|
|
1197
|
+
joined: bool,
|
|
1198
|
+
) -> None:
|
|
1199
|
+
session_id = session_id_from_access_token(session.access_token)
|
|
1200
|
+
verified = owner.has_verified_pair(session)
|
|
1201
|
+
if session_id is not None and not verified:
|
|
1202
|
+
self._revoke_access_session(
|
|
1203
|
+
session, session_id, refresh_error, joined=joined
|
|
1204
|
+
)
|
|
1205
|
+
return
|
|
1206
|
+
if refresh_error is not None and not verified:
|
|
1207
|
+
raise refresh_error
|
|
1208
|
+
if session.refresh_token is not None:
|
|
1209
|
+
transport = cast("AuthLogoutTransport", self._client._transport)
|
|
1210
|
+
response = invoke(
|
|
1211
|
+
transport.auth_logout,
|
|
1212
|
+
authorization=self._client._anon_token(),
|
|
1213
|
+
refresh_token=session.refresh_token,
|
|
1214
|
+
)
|
|
1215
|
+
else:
|
|
1216
|
+
return
|
|
1217
|
+
response_payload(response, 204)
|
|
1218
|
+
|
|
1219
|
+
def _revoke_access_session(
|
|
1220
|
+
self,
|
|
1221
|
+
session: Session,
|
|
1222
|
+
session_id: str,
|
|
1223
|
+
refresh_error: VolcanoError | None,
|
|
1224
|
+
*,
|
|
1225
|
+
joined: bool,
|
|
1226
|
+
) -> None:
|
|
1227
|
+
transport = cast("AuthDeleteMySessionTransport", self._client._transport)
|
|
1228
|
+
response = invoke(
|
|
1229
|
+
transport.auth_delete_my_session,
|
|
1230
|
+
authorization=session.access_token,
|
|
1231
|
+
session_id=session_id,
|
|
1232
|
+
)
|
|
1233
|
+
if (
|
|
1234
|
+
response.status_code == HTTPStatus.UNAUTHORIZED
|
|
1235
|
+
and session.refresh_token is not None
|
|
1236
|
+
):
|
|
1237
|
+
if refresh_error is not None:
|
|
1238
|
+
raise refresh_error
|
|
1239
|
+
if joined:
|
|
1240
|
+
response_payload(response, 204)
|
|
1241
|
+
return
|
|
1242
|
+
refreshed = self._request_refreshed_session(session.refresh_token)
|
|
1243
|
+
validate_refresh_identity(session, refreshed)
|
|
1244
|
+
response = invoke(
|
|
1245
|
+
transport.auth_delete_my_session,
|
|
1246
|
+
authorization=refreshed.access_token,
|
|
1247
|
+
session_id=session_id,
|
|
1248
|
+
)
|
|
1249
|
+
response_payload(response, 204)
|
volcano_sdk/client.py
CHANGED
|
@@ -5,9 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import threading
|
|
6
6
|
from collections import deque
|
|
7
7
|
from dataclasses import replace
|
|
8
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from typing import TYPE_CHECKING, TypedDict, Unpack
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
11
|
+
from ._session import validate_refresh_identity
|
|
12
|
+
from ._session_operations import SessionOperations
|
|
11
13
|
from ._transport import GeneratedTransport, Transport
|
|
12
14
|
from .auth import Auth
|
|
13
15
|
from .database import Database
|
|
@@ -32,6 +34,39 @@ if TYPE_CHECKING:
|
|
|
32
34
|
_NO_ACTIVE_SESSION = "No active session"
|
|
33
35
|
_NO_SERVICE_KEY = "No service key configured"
|
|
34
36
|
_PROFILE_USER_MISMATCH = "Profile user does not match the active session"
|
|
37
|
+
_BOOTSTRAP_ACCESS_REQUIRED = "refresh_token requires access_token"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _BootstrapCredentials(TypedDict, total=False):
|
|
41
|
+
access_token: str | None
|
|
42
|
+
refresh_token: str | None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_bootstrap_credential(name: str, token: object) -> None:
|
|
46
|
+
if token is not None and (not isinstance(token, str) or not token.strip()):
|
|
47
|
+
message = f"{name} must be a non-empty string"
|
|
48
|
+
raise ValueError(message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bootstrap_session(
|
|
52
|
+
credentials: _BootstrapCredentials,
|
|
53
|
+
) -> Session | None:
|
|
54
|
+
unknown = credentials.keys() - {"access_token", "refresh_token"}
|
|
55
|
+
if unknown:
|
|
56
|
+
message = f"Unexpected keyword argument: {next(iter(unknown))}"
|
|
57
|
+
raise TypeError(message)
|
|
58
|
+
access_token = credentials.get("access_token")
|
|
59
|
+
refresh_token = credentials.get("refresh_token")
|
|
60
|
+
if access_token is None:
|
|
61
|
+
if refresh_token is not None:
|
|
62
|
+
raise ValueError(_BOOTSTRAP_ACCESS_REQUIRED)
|
|
63
|
+
return None
|
|
64
|
+
for name, token in (
|
|
65
|
+
("access_token", access_token),
|
|
66
|
+
("refresh_token", refresh_token),
|
|
67
|
+
):
|
|
68
|
+
_validate_bootstrap_credential(name, token)
|
|
69
|
+
return Session(access_token=access_token, refresh_token=refresh_token)
|
|
35
70
|
|
|
36
71
|
|
|
37
72
|
class _CallbackOutcome:
|
|
@@ -65,6 +100,7 @@ class VolcanoClient:
|
|
|
65
100
|
timeout: float = 60.0,
|
|
66
101
|
_transport: Transport | None = None,
|
|
67
102
|
_realtime_client_factory: CentrifugeFactory | None = None,
|
|
103
|
+
**credentials: Unpack[_BootstrapCredentials],
|
|
68
104
|
) -> None:
|
|
69
105
|
"""Create a client for a Volcano project."""
|
|
70
106
|
self._api_url = api_url.rstrip("/")
|
|
@@ -72,8 +108,8 @@ class VolcanoClient:
|
|
|
72
108
|
self._service_key = service_key
|
|
73
109
|
self._session_lock = threading.Lock()
|
|
74
110
|
self._session_generation = 0
|
|
75
|
-
self._session_lineage =
|
|
76
|
-
self._current_session
|
|
111
|
+
self._session_lineage = SessionOperations()
|
|
112
|
+
self._current_session = _bootstrap_session(credentials)
|
|
77
113
|
self._auth_callbacks: dict[int, AuthStateCallback] = {}
|
|
78
114
|
self._next_auth_callback_id = 0
|
|
79
115
|
self._auth_notifications: deque[
|
|
@@ -146,7 +182,7 @@ class VolcanoClient:
|
|
|
146
182
|
with self._session_lock:
|
|
147
183
|
self._current_session = session
|
|
148
184
|
self._session_generation += 1
|
|
149
|
-
self._session_lineage
|
|
185
|
+
self._session_lineage = SessionOperations()
|
|
150
186
|
if event is None:
|
|
151
187
|
return
|
|
152
188
|
callback_ids = tuple(self._auth_callbacks)
|
|
@@ -158,7 +194,7 @@ class VolcanoClient:
|
|
|
158
194
|
with self._session_lock:
|
|
159
195
|
return self._session_generation, self._current_session
|
|
160
196
|
|
|
161
|
-
def _capture_session_binding(self) -> tuple[int,
|
|
197
|
+
def _capture_session_binding(self) -> tuple[int, SessionOperations, Session | None]:
|
|
162
198
|
with self._session_lock:
|
|
163
199
|
return (
|
|
164
200
|
self._session_generation,
|
|
@@ -173,15 +209,16 @@ class VolcanoClient:
|
|
|
173
209
|
current = self._current_session
|
|
174
210
|
if generation != self._session_generation or current is None:
|
|
175
211
|
return False
|
|
212
|
+
user_id = str(user["id"]) if current.user_id is None else current.user_id
|
|
176
213
|
try:
|
|
177
|
-
same_user = UUID(str(user["id"])) == UUID(
|
|
214
|
+
same_user = UUID(str(user["id"])) == UUID(user_id)
|
|
178
215
|
except ValueError:
|
|
179
216
|
same_user = False
|
|
180
217
|
if not same_user:
|
|
181
218
|
raise AuthenticationError(_PROFILE_USER_MISMATCH)
|
|
182
219
|
# Profile updates do not replace credentials or invalidate other requests.
|
|
183
220
|
self._current_session = replace(
|
|
184
|
-
current, user={**user, "id":
|
|
221
|
+
current, user_id=user_id, user={**user, "id": user_id}
|
|
185
222
|
)
|
|
186
223
|
return True
|
|
187
224
|
|
|
@@ -196,10 +233,12 @@ class VolcanoClient:
|
|
|
196
233
|
with self._session_lock:
|
|
197
234
|
if generation != self._session_generation:
|
|
198
235
|
return False
|
|
236
|
+
if event == "TOKEN_REFRESHED":
|
|
237
|
+
validate_refresh_identity(self._current_session, session)
|
|
199
238
|
self._current_session = session
|
|
200
239
|
self._session_generation += 1
|
|
201
240
|
if event != "TOKEN_REFRESHED":
|
|
202
|
-
self._session_lineage
|
|
241
|
+
self._session_lineage = SessionOperations(session)
|
|
203
242
|
callback_ids = tuple(self._auth_callbacks)
|
|
204
243
|
dispatch = self._enqueue_auth_state_change(callback_ids, event, session)
|
|
205
244
|
if dispatch:
|
|
@@ -210,15 +249,21 @@ class VolcanoClient:
|
|
|
210
249
|
self,
|
|
211
250
|
generation: int,
|
|
212
251
|
*,
|
|
252
|
+
lineage: SessionOperations | None = None,
|
|
213
253
|
event: AuthChangeEvent = "SIGNED_OUT",
|
|
214
254
|
notifications: list[Callable[[], None]] | None = None,
|
|
215
255
|
) -> bool:
|
|
216
256
|
with self._session_lock:
|
|
217
|
-
if
|
|
257
|
+
if lineage is not None:
|
|
258
|
+
if lineage != self._session_lineage:
|
|
259
|
+
return False
|
|
260
|
+
elif generation != self._session_generation:
|
|
218
261
|
return False
|
|
262
|
+
if self._current_session is None:
|
|
263
|
+
return True
|
|
219
264
|
self._current_session = None
|
|
265
|
+
self._session_lineage.clear_local_credentials()
|
|
220
266
|
self._session_generation += 1
|
|
221
|
-
self._session_lineage += 1
|
|
222
267
|
callback_ids = tuple(self._auth_callbacks)
|
|
223
268
|
dispatch = self._enqueue_auth_state_change(callback_ids, event, None)
|
|
224
269
|
if dispatch:
|
volcano_sdk/locks.py
CHANGED
|
@@ -5,11 +5,12 @@ from __future__ import annotations
|
|
|
5
5
|
from contextlib import contextmanager, suppress
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from typing import TYPE_CHECKING, Protocol, cast
|
|
8
|
-
from uuid import uuid4
|
|
8
|
+
from uuid import UUID, uuid4
|
|
9
9
|
|
|
10
10
|
from ._lock_guard import LockGuard, _lease_now
|
|
11
11
|
from ._lock_worker import LockRenewer
|
|
12
12
|
from ._transport import Transport, invoke, response_payload
|
|
13
|
+
from .errors import ServerError, TransportError
|
|
13
14
|
from .models import LockLease, LockState
|
|
14
15
|
|
|
15
16
|
if TYPE_CHECKING:
|
|
@@ -37,6 +38,7 @@ class LockGetTransport(Protocol):
|
|
|
37
38
|
*,
|
|
38
39
|
authorization: str,
|
|
39
40
|
key: str,
|
|
41
|
+
request_id: str | None = None,
|
|
40
42
|
) -> object:
|
|
41
43
|
"""Get one project-scoped lock."""
|
|
42
44
|
...
|
|
@@ -52,6 +54,7 @@ class LockRenewTransport(Protocol):
|
|
|
52
54
|
key: str,
|
|
53
55
|
ttl: int,
|
|
54
56
|
token: str,
|
|
57
|
+
request_id: str | None = None,
|
|
55
58
|
) -> object:
|
|
56
59
|
"""Renew one project-scoped lock."""
|
|
57
60
|
...
|
|
@@ -65,6 +68,7 @@ class LockForceReleaseTransport(Protocol):
|
|
|
65
68
|
*,
|
|
66
69
|
authorization: str,
|
|
67
70
|
key: str,
|
|
71
|
+
request_id: str | None = None,
|
|
68
72
|
) -> object:
|
|
69
73
|
"""Force release one project-scoped lock."""
|
|
70
74
|
...
|
|
@@ -85,6 +89,17 @@ def _validate_ttl(ttl: object) -> None:
|
|
|
85
89
|
raise ValueError(_INVALID_LOCK_TTL)
|
|
86
90
|
|
|
87
91
|
|
|
92
|
+
def _request_uuid(value: str | None, name: str) -> str:
|
|
93
|
+
if value is None:
|
|
94
|
+
return str(uuid4())
|
|
95
|
+
try:
|
|
96
|
+
UUID(value)
|
|
97
|
+
except (AttributeError, ValueError) as error:
|
|
98
|
+
message = f"{name} must be a UUID string"
|
|
99
|
+
raise ValueError(message) from error
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
88
103
|
class Locks:
|
|
89
104
|
"""Acquire and release project-scoped distributed locks."""
|
|
90
105
|
|
|
@@ -92,13 +107,14 @@ class Locks:
|
|
|
92
107
|
"""Create a lock facade backed by a client."""
|
|
93
108
|
self._client = client
|
|
94
109
|
|
|
95
|
-
def get(self, key: str) -> LockState:
|
|
110
|
+
def get(self, key: str, *, request_id: str | None = None) -> LockState:
|
|
96
111
|
"""Return the current state of a project-scoped lock."""
|
|
97
112
|
transport = cast("LockGetTransport", self._client._transport)
|
|
98
113
|
response = invoke(
|
|
99
114
|
transport.get_project_lock,
|
|
100
115
|
authorization=self._client._service_token(),
|
|
101
116
|
key=key,
|
|
117
|
+
request_id=_request_uuid(request_id, "request_id"),
|
|
102
118
|
)
|
|
103
119
|
payload = response_payload(response, 200)
|
|
104
120
|
return LockState(
|
|
@@ -107,26 +123,64 @@ class Locks:
|
|
|
107
123
|
fencing_token=payload.get("fencing_token"),
|
|
108
124
|
)
|
|
109
125
|
|
|
110
|
-
def acquire(
|
|
111
|
-
|
|
126
|
+
def acquire(
|
|
127
|
+
self,
|
|
128
|
+
key: str,
|
|
129
|
+
*,
|
|
130
|
+
ttl: int,
|
|
131
|
+
token: str | None = None,
|
|
132
|
+
request_id: str | None = None,
|
|
133
|
+
) -> LockLease:
|
|
134
|
+
"""Acquire with one bounded retry using the same ownership token."""
|
|
135
|
+
lease, _ = self._acquire_with_start(
|
|
136
|
+
key, ttl=ttl, token=token, request_id=request_id
|
|
137
|
+
)
|
|
138
|
+
return lease
|
|
139
|
+
|
|
140
|
+
def _acquire_with_start(
|
|
141
|
+
self,
|
|
142
|
+
key: str,
|
|
143
|
+
*,
|
|
144
|
+
ttl: int,
|
|
145
|
+
token: str | None,
|
|
146
|
+
request_id: str | None,
|
|
147
|
+
) -> tuple[LockLease, float]:
|
|
112
148
|
_validate_ttl(ttl)
|
|
113
|
-
token =
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
149
|
+
token = _request_uuid(token, "token")
|
|
150
|
+
request_id = _request_uuid(request_id, "request_id")
|
|
151
|
+
authorization = self._client._service_token()
|
|
152
|
+
started_at = _lease_now()
|
|
153
|
+
try:
|
|
154
|
+
payload = self._acquire_payload(key, ttl, token, request_id, authorization)
|
|
155
|
+
except (TransportError, ServerError) as error:
|
|
156
|
+
if error.status not in (None, 503):
|
|
157
|
+
raise
|
|
158
|
+
started_at = _lease_now()
|
|
159
|
+
payload = self._acquire_payload(key, ttl, token, request_id, authorization)
|
|
160
|
+
lease = LockLease(
|
|
117
161
|
key=key,
|
|
118
|
-
ttl=ttl,
|
|
119
162
|
token=token,
|
|
163
|
+
expires_at=_parse_datetime(payload.get("expires_at")),
|
|
164
|
+
fencing_token=cast("int | None", payload.get("fencing_token")),
|
|
120
165
|
)
|
|
121
|
-
|
|
122
|
-
|
|
166
|
+
return lease, started_at
|
|
167
|
+
|
|
168
|
+
def _acquire_payload(
|
|
169
|
+
self, key: str, ttl: int, token: str, request_id: str, authorization: str
|
|
170
|
+
) -> dict[str, object]:
|
|
171
|
+
response = invoke(
|
|
172
|
+
self._client._transport.acquire_project_lock,
|
|
173
|
+
authorization=authorization,
|
|
123
174
|
key=key,
|
|
175
|
+
ttl=ttl,
|
|
124
176
|
token=token,
|
|
125
|
-
|
|
126
|
-
fencing_token=payload.get("fencing_token"),
|
|
177
|
+
request_id=request_id,
|
|
127
178
|
)
|
|
179
|
+
return cast("dict[str, object]", response_payload(response, 201))
|
|
128
180
|
|
|
129
|
-
def renew(
|
|
181
|
+
def renew(
|
|
182
|
+
self, key: str, lease: LockLease, *, ttl: int, request_id: str | None = None
|
|
183
|
+
) -> LockLease:
|
|
130
184
|
"""Renew a lock lease and return its immutable replacement."""
|
|
131
185
|
_validate_ttl(ttl)
|
|
132
186
|
transport = cast("LockRenewTransport", self._client._transport)
|
|
@@ -134,6 +188,7 @@ class Locks:
|
|
|
134
188
|
transport.renew_project_lock,
|
|
135
189
|
authorization=self._client._service_token(),
|
|
136
190
|
key=key,
|
|
191
|
+
request_id=_request_uuid(request_id, "request_id"),
|
|
137
192
|
ttl=ttl,
|
|
138
193
|
token=lease.token,
|
|
139
194
|
)
|
|
@@ -145,35 +200,47 @@ class Locks:
|
|
|
145
200
|
fencing_token=payload.get("fencing_token"),
|
|
146
201
|
)
|
|
147
202
|
|
|
148
|
-
def release(
|
|
203
|
+
def release(
|
|
204
|
+
self, key: str, lease: LockLease, *, request_id: str | None = None
|
|
205
|
+
) -> None:
|
|
149
206
|
"""Release a lock lease."""
|
|
150
207
|
response = invoke(
|
|
151
208
|
self._client._transport.release_project_lock,
|
|
152
209
|
authorization=self._client._service_token(),
|
|
153
210
|
key=key,
|
|
211
|
+
request_id=_request_uuid(request_id, "request_id"),
|
|
154
212
|
token=lease.token,
|
|
155
213
|
)
|
|
156
214
|
response_payload(response, 204)
|
|
157
215
|
|
|
158
|
-
def force_release(self, key: str) -> None:
|
|
216
|
+
def force_release(self, key: str, *, request_id: str | None = None) -> None:
|
|
159
217
|
"""Release a lock regardless of which token owns it."""
|
|
160
218
|
transport = cast("LockForceReleaseTransport", self._client._transport)
|
|
161
219
|
response = invoke(
|
|
162
220
|
transport.force_release_project_lock,
|
|
163
221
|
authorization=self._client._service_token(),
|
|
164
222
|
key=key,
|
|
223
|
+
request_id=_request_uuid(request_id, "request_id"),
|
|
165
224
|
)
|
|
166
225
|
response_payload(response, 204)
|
|
167
226
|
|
|
168
227
|
@contextmanager
|
|
169
|
-
def with_lock(
|
|
228
|
+
def with_lock(
|
|
229
|
+
self,
|
|
230
|
+
key: str,
|
|
231
|
+
*,
|
|
232
|
+
ttl: int,
|
|
233
|
+
token: str | None = None,
|
|
234
|
+
request_id: str | None = None,
|
|
235
|
+
) -> Generator[LockGuard, None, None]:
|
|
170
236
|
"""Hold and automatically renew a lock for the context's lifetime."""
|
|
171
237
|
_validate_ttl(ttl)
|
|
172
238
|
started_at = _lease_now()
|
|
239
|
+
lease, lease_started_at = self._acquire_with_start(
|
|
240
|
+
key, ttl=ttl, token=token, request_id=request_id
|
|
241
|
+
)
|
|
173
242
|
guard = LockGuard(
|
|
174
|
-
|
|
175
|
-
ttl=ttl,
|
|
176
|
-
started_at=started_at,
|
|
243
|
+
lease, ttl=ttl, started_at=started_at, lease_started_at=lease_started_at
|
|
177
244
|
)
|
|
178
245
|
renewer = LockRenewer(self, key, guard, ttl=ttl)
|
|
179
246
|
renewer_started = False
|
volcano_sdk/models.py
CHANGED
|
@@ -84,11 +84,11 @@ class User:
|
|
|
84
84
|
|
|
85
85
|
@dataclass(frozen=True, slots=True)
|
|
86
86
|
class Session:
|
|
87
|
-
"""
|
|
87
|
+
"""Local credentials with optional refresh credentials and user identity."""
|
|
88
88
|
|
|
89
89
|
access_token: str
|
|
90
|
-
refresh_token: str
|
|
91
|
-
user_id: str
|
|
90
|
+
refresh_token: str | None = None
|
|
91
|
+
user_id: str | None = None
|
|
92
92
|
user: Mapping[str, JSONValue] | None = field(default=None, repr=False, hash=False)
|
|
93
93
|
|
|
94
94
|
def __post_init__(self) -> None:
|
volcano_sdk/realtime.py
CHANGED
|
@@ -25,6 +25,7 @@ from ._transport import (
|
|
|
25
25
|
from .models import JSONValue, _freeze_json
|
|
26
26
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
28
|
+
from ._session_operations import SessionOperations
|
|
28
29
|
from .models import Session
|
|
29
30
|
|
|
30
31
|
MessageCallback = Callable[[Any], Any]
|
|
@@ -205,7 +206,7 @@ def _postgres_fetch_config(
|
|
|
205
206
|
|
|
206
207
|
@dataclass(frozen=True, slots=True)
|
|
207
208
|
class _PostgresDeliveryIdentity:
|
|
208
|
-
session_lineage:
|
|
209
|
+
session_lineage: SessionOperations | None
|
|
209
210
|
subscription_epoch: int
|
|
210
211
|
|
|
211
212
|
|
|
@@ -282,7 +283,9 @@ class RealtimeContext(Protocol):
|
|
|
282
283
|
|
|
283
284
|
def _session_token(self) -> str: ...
|
|
284
285
|
|
|
285
|
-
def _capture_session_binding(
|
|
286
|
+
def _capture_session_binding(
|
|
287
|
+
self,
|
|
288
|
+
) -> tuple[int, SessionOperations, Session | None]: ...
|
|
286
289
|
|
|
287
290
|
|
|
288
291
|
class CentrifugeSubscription(Protocol):
|
|
@@ -608,7 +611,7 @@ class Channel:
|
|
|
608
611
|
self._callback_task: asyncio.Task[None] | None = None
|
|
609
612
|
self._pending_presence_sync: Any = NO_PENDING_CALLBACK
|
|
610
613
|
self._postgres_epoch = 0
|
|
611
|
-
self._postgres_session_lineage =
|
|
614
|
+
self._postgres_session_lineage: SessionOperations | None = None
|
|
612
615
|
self._postgres_lock = asyncio.Lock()
|
|
613
616
|
self._postgres_worker: PostgresFetchWorker[_PostgresDelivery] | None = None
|
|
614
617
|
self._postgres_filters: dict[
|
|
@@ -732,11 +735,12 @@ class Channel:
|
|
|
732
735
|
self,
|
|
733
736
|
identity: _PostgresDeliveryIdentity,
|
|
734
737
|
) -> bool:
|
|
735
|
-
_generation, lineage,
|
|
738
|
+
_generation, lineage, session = (
|
|
736
739
|
self._realtime._client_context._capture_session_binding()
|
|
737
740
|
)
|
|
738
741
|
return (
|
|
739
742
|
self._subscribed
|
|
743
|
+
and session is not None
|
|
740
744
|
and identity.subscription_epoch == self._postgres_epoch
|
|
741
745
|
and identity.session_lineage == lineage
|
|
742
746
|
)
|
|
@@ -1161,7 +1165,7 @@ class Realtime:
|
|
|
1161
1165
|
self._api_url = api_url
|
|
1162
1166
|
self._client_factory = client_factory
|
|
1163
1167
|
self._connection: _VolcanoCentrifugeConnection | None = None
|
|
1164
|
-
self._connection_session_lineage:
|
|
1168
|
+
self._connection_session_lineage: SessionOperations | None = None
|
|
1165
1169
|
self._connection_access_token: str | None = None
|
|
1166
1170
|
self._connection_lock = asyncio.Lock()
|
|
1167
1171
|
self._channels: dict[str, Channel] = {}
|
|
@@ -1401,7 +1405,7 @@ class Realtime:
|
|
|
1401
1405
|
self._connection_access_token = session.access_token
|
|
1402
1406
|
return session.access_token
|
|
1403
1407
|
|
|
1404
|
-
def _session_for_lineage(self, expected_lineage:
|
|
1408
|
+
def _session_for_lineage(self, expected_lineage: SessionOperations) -> Session:
|
|
1405
1409
|
_generation, lineage, session = self._client_context._capture_session_binding()
|
|
1406
1410
|
if session is None:
|
|
1407
1411
|
raise RuntimeError(NO_ACTIVE_SESSION)
|
|
@@ -1409,7 +1413,7 @@ class Realtime:
|
|
|
1409
1413
|
raise RuntimeError(CONNECTION_SESSION_CHANGED)
|
|
1410
1414
|
return session
|
|
1411
1415
|
|
|
1412
|
-
def _connection_lineage(self) ->
|
|
1416
|
+
def _connection_lineage(self) -> SessionOperations:
|
|
1413
1417
|
lineage = self._connection_session_lineage
|
|
1414
1418
|
if lineage is None:
|
|
1415
1419
|
raise RuntimeError(CONNECTION_SESSION_UNAVAILABLE)
|
volcano_sdk/storage.py
CHANGED
|
@@ -35,6 +35,7 @@ from .models import (
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
if TYPE_CHECKING:
|
|
38
|
+
from ._session_operations import SessionOperations
|
|
38
39
|
from .auth import Auth
|
|
39
40
|
|
|
40
41
|
_INVALID_STORAGE_PAGE = "Expected a complete storage page"
|
|
@@ -292,7 +293,9 @@ class StorageContext(Protocol):
|
|
|
292
293
|
|
|
293
294
|
def _session_token(self) -> str: ...
|
|
294
295
|
|
|
295
|
-
def _capture_session_binding(
|
|
296
|
+
def _capture_session_binding(
|
|
297
|
+
self,
|
|
298
|
+
) -> tuple[int, SessionOperations, Session | None]: ...
|
|
296
299
|
|
|
297
300
|
|
|
298
301
|
class StorageListTransport(Protocol):
|
|
@@ -707,7 +710,9 @@ class StorageBucket:
|
|
|
707
710
|
self._remove_path(path, binding)
|
|
708
711
|
return path_list
|
|
709
712
|
|
|
710
|
-
def _remove_path(
|
|
713
|
+
def _remove_path(
|
|
714
|
+
self, path: str, binding: tuple[int, SessionOperations, Session | None]
|
|
715
|
+
) -> None:
|
|
711
716
|
transport = cast("StorageDeleteTransport", self._client._transport)
|
|
712
717
|
response = self._client.auth._session_request(
|
|
713
718
|
lambda token: invoke(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: volcano-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Official Python SDK for Volcano
|
|
5
5
|
Project-URL: Homepage, https://volcano.dev
|
|
6
6
|
Project-URL: Repository, https://github.com/Kong/volcano-sdk-python
|
|
@@ -182,6 +182,13 @@ original error. `on_progress` runs after each successful part with cumulative
|
|
|
182
182
|
uploaded bytes and the total size.
|
|
183
183
|
`upload_part()` returns immutable part metadata and can safely retry the same
|
|
184
184
|
part number to replace that part.
|
|
185
|
+
Acquisition accepts caller-owned UUID `token` and `request_id` values and retries
|
|
186
|
+
an ambiguous transport failure or HTTP 503 once with the same request and credential.
|
|
187
|
+
Retain those IDs to recover an uncertain acquisition. Other lock methods accept
|
|
188
|
+
`request_id`; block-scoped helpers forward initial IDs only to acquisition.
|
|
189
|
+
See the [lock guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/locks.md)
|
|
190
|
+
for examples and fencing requirements.
|
|
191
|
+
|
|
185
192
|
`locks.get()` returns immutable lock availability, expiry, and fencing-token
|
|
186
193
|
state without acquiring the lock.
|
|
187
194
|
Lock acquisition and renewal require an integer TTL from 5 seconds through 90 days.
|
|
@@ -586,6 +593,14 @@ Success returns `None`. The reset revokes the recovered account's existing sessi
|
|
|
586
593
|
sign it in. The client keeps any unrelated local session unchanged; sign in with the new password
|
|
587
594
|
when the reset flow completes.
|
|
588
595
|
|
|
596
|
+
To start with only a supplied user access token, pass `access_token` to
|
|
597
|
+
`VolcanoClient`. Construction makes no request and leaves `refresh_token`,
|
|
598
|
+
`user_id`, and `user` as `None` until supplied or validated by the server.
|
|
599
|
+
`get_user()` validates and caches the profile without changing credentials.
|
|
600
|
+
Without a refresh token, `refresh_session()` raises `AuthenticationError` and
|
|
601
|
+
`sign_out()` revokes the server session using the access token and clears local state.
|
|
602
|
+
Supply `refresh_token` with `access_token` to enable refresh. See the [token bootstrap example](https://github.com/Kong/volcano-sdk-python/blob/main/docs/README.md#use-a-supplied-access-token).
|
|
603
|
+
|
|
589
604
|
Copy a complete native session into another client's memory:
|
|
590
605
|
|
|
591
606
|
```python
|
|
@@ -643,9 +658,13 @@ client.auth.sign_out()
|
|
|
643
658
|
assert client.auth.get_session() is None
|
|
644
659
|
```
|
|
645
660
|
|
|
661
|
+
Sign-out uses the refresh token directly when the SDK received both credentials together from
|
|
662
|
+
sign-in or a validated refresh. Supplied credentials use the access-token session; on HTTP 401,
|
|
663
|
+
the SDK can refresh once and revoke that same session without adopting the renewed credentials.
|
|
646
664
|
Calling `sign_out()` without a session succeeds without a request. A revocation failure is raised
|
|
647
|
-
after the captured local session is cleared.
|
|
648
|
-
|
|
665
|
+
after the captured local session is cleared. Sign-out waits for an already-running refresh and uses its validated credentials.
|
|
666
|
+
Later refresh attempts raise `SessionChangedError` without a request. Concurrent sign-out calls
|
|
667
|
+
share one result. A separate sign-in or adoption remains current.
|
|
649
668
|
|
|
650
669
|
Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
|
|
651
670
|
subscription objects are not part of the public API.
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
volcano_sdk/__init__.py,sha256=rPSFMLjFbxeQvSRkL1Ee8MsNWArIhs8fgNFgtDuMNzI,1954
|
|
2
2
|
volcano_sdk/_function_resolution.py,sha256=_gC3LY6x71NtJc_o9d9eq3N27Z-T_Un3p2-XRu2-Cqs,5088
|
|
3
|
-
volcano_sdk/_lock_guard.py,sha256=
|
|
3
|
+
volcano_sdk/_lock_guard.py,sha256=VhHTgBc4e5EDj8j15vVIhdBOTp6fvD628x3hVBeF39o,5794
|
|
4
4
|
volcano_sdk/_lock_renewer.py,sha256=tFo1ZeRiPKB5VSbW61uoMpNR67ZDYyMEe-dCjpAM3As,841
|
|
5
5
|
volcano_sdk/_lock_worker.py,sha256=QhSmwR0WcwgT-7ueJMlbCogLqOakQ5eRAtyWV74fgV0,3057
|
|
6
6
|
volcano_sdk/_realtime_fetch_worker.py,sha256=zQTgFF4cIUBeAL4J2OpWAlz_sIynv2BLfu588EogAdo,9269
|
|
7
|
-
volcano_sdk/
|
|
8
|
-
volcano_sdk/
|
|
9
|
-
volcano_sdk/
|
|
7
|
+
volcano_sdk/_session.py,sha256=LYCv2NCDlL5Slh2YLEG8VQ8z-kDaKDYWW4vGdMvUf1U,2474
|
|
8
|
+
volcano_sdk/_session_operations.py,sha256=1vI9gtwjxG2eUGT-nIYnMEgRldJqgIj2lRLukBbG64E,5518
|
|
9
|
+
volcano_sdk/_transport.py,sha256=AFd47C3X1WAHwq3f9V2ux0WNzwGIn4NBxrwgaIN-xl0,58296
|
|
10
|
+
volcano_sdk/auth.py,sha256=7eCaA9DFsJYI4KHBXG6UXNM8QZLpvedNbCwXTfozgMI,46518
|
|
11
|
+
volcano_sdk/client.py,sha256=CJJnf_iTcXnyY2Y57LXJVAn39mTUsepblUg9op-Thzo,12283
|
|
10
12
|
volcano_sdk/connection_string.py,sha256=LlTZigsrCF4IfAqQ8SDTWJv35hnnzNu1MfeVv5JW-Kk,2331
|
|
11
13
|
volcano_sdk/database.py,sha256=p_5OaqlBjKAtIUfKQCflXM_CGqokrVmpOMsp6KHiJ8A,9493
|
|
12
14
|
volcano_sdk/errors.py,sha256=ffs66FIN_jhyh5SMj7KdXb-zVV3DSgVK6xJEPNA5h9Y,1613
|
|
13
15
|
volcano_sdk/functions.py,sha256=zDeAKyCieNnI6wzb9iZMlg9_cbjaYWXYbXHb7jzCle4,10126
|
|
14
|
-
volcano_sdk/locks.py,sha256=
|
|
16
|
+
volcano_sdk/locks.py,sha256=eSkidfHQ_wml5-1LT2hBtHxeALP6E1cGYK0FzHjbrNk,9241
|
|
15
17
|
volcano_sdk/logs.py,sha256=lxORRQBvsnJ0BdMau19MXrIeig5_mEucQSyJ4F1CZxU,4666
|
|
16
|
-
volcano_sdk/models.py,sha256=
|
|
18
|
+
volcano_sdk/models.py,sha256=n5EdzkWzmOstXsuFlvfGOILRemQAqG2P4VCLMelW8SA,8690
|
|
17
19
|
volcano_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
18
|
-
volcano_sdk/realtime.py,sha256=
|
|
19
|
-
volcano_sdk/storage.py,sha256=
|
|
20
|
+
volcano_sdk/realtime.py,sha256=3p5ueLCeveaR4_ItqlLv-exkMCODqxDQNHj1aYB_YE4,59506
|
|
21
|
+
volcano_sdk/storage.py,sha256=p16OV5ADI6JjMAJ7gx2gcmwk9t8--YiIZcOSLQLVlDY,26238
|
|
20
22
|
volcano_sdk/_generated/__init__.py,sha256=ZlB8wMYdN6EjpUS5W_lX3jsxq9G1kOcmUr-j3Fccfn8,164
|
|
21
23
|
volcano_sdk/_generated/client.py,sha256=GDzw6T2uRmPlUb1DImpBGxwy0dNbhmBknfvvIQW0GuU,12387
|
|
22
24
|
volcano_sdk/_generated/errors.py,sha256=trp-p5qn1_JLRxGZhdHtICaNPaCrcDCe4TgIihBravk,546
|
|
@@ -773,7 +775,7 @@ volcano_sdk/_generated/models/usage_data_point.py,sha256=d4U08JbVTiIMM_l2BmRILL1
|
|
|
773
775
|
volcano_sdk/_generated/models/variable.py,sha256=tvGDOMv77gW6j5skufnfrlNAhzCzA1F7nuPCQWx79yY,5926
|
|
774
776
|
volcano_sdk/_generated/models/variable_deploy_source.py,sha256=G_aCiFHzQx8WhJFs0GvYZEFclfTrfkDvd3kT1e3vty8,463
|
|
775
777
|
volcano_sdk/_generated/models/variable_status.py,sha256=THSsKBSiAYG4kAtBzGA4TZqxjgx8hdL0Q4qCdZfOj00,391
|
|
776
|
-
volcano_sdk_python-0.
|
|
777
|
-
volcano_sdk_python-0.
|
|
778
|
-
volcano_sdk_python-0.
|
|
779
|
-
volcano_sdk_python-0.
|
|
778
|
+
volcano_sdk_python-0.9.0.dist-info/METADATA,sha256=RKoQTUaTeDCZvyej7-w-j6bQIizIf2DYEN-B5dGnCt8,35125
|
|
779
|
+
volcano_sdk_python-0.9.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
780
|
+
volcano_sdk_python-0.9.0.dist-info/licenses/LICENSE,sha256=AIHJy-5MFqM8fHfuGFTTb9a8rvJGypcVgN4aZ-7XTWw,11387
|
|
781
|
+
volcano_sdk_python-0.9.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|