cliV1 0.0.8__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.
- cliv1-0.0.8.dist-info/METADATA +168 -0
- cliv1-0.0.8.dist-info/RECORD +87 -0
- cliv1-0.0.8.dist-info/WHEEL +4 -0
- cliv1-0.0.8.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_0/__init__.py +15 -0
- swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_0/apis/pet_api.py +883 -0
- swagger_petstore_open_api_3_0/apis/store.py +413 -0
- swagger_petstore_open_api_3_0/apis/user_api.py +794 -0
- swagger_petstore_open_api_3_0/async_client.py +79 -0
- swagger_petstore_open_api_3_0/auth.py +17 -0
- swagger_petstore_open_api_3_0/base_client.py +18 -0
- swagger_petstore_open_api_3_0/client.py +77 -0
- swagger_petstore_open_api_3_0/core/__init__.py +358 -0
- swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
- swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
- swagger_petstore_open_api_3_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
- swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
- swagger_petstore_open_api_3_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_0/core/bodies.py +550 -0
- swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
- swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
- swagger_petstore_open_api_3_0/core/files.py +173 -0
- swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
- swagger_petstore_open_api_3_0/core/models.py +143 -0
- swagger_petstore_open_api_3_0/core/optionality.py +130 -0
- swagger_petstore_open_api_3_0/core/params.py +157 -0
- swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
- swagger_petstore_open_api_3_0/core/request_options.py +94 -0
- swagger_petstore_open_api_3_0/core/results.py +123 -0
- swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_0/core/transport.py +191 -0
- swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_0/models/category.py +15 -0
- swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_0/models/order.py +27 -0
- swagger_petstore_open_api_3_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_0/models/user.py +29 -0
- swagger_petstore_open_api_3_0/py.typed +0 -0
- swagger_petstore_open_api_3_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_0/server/server.py +17 -0
- swagger_petstore_open_api_3_0/server/server_config.py +52 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""``oauth2`` / ``password`` -- RFC 6749 §4.3, the resource owner password credentials grant.
|
|
2
|
+
|
|
3
|
+
Two credential pairs on one request: the resource owner's, always as form fields, and the client's
|
|
4
|
+
own, wherever ``x-oauth2-credentials-placement`` puts them.
|
|
5
|
+
|
|
6
|
+
**Plain tier, not refreshable.** §4.3.3 permits a refresh token here and this ignores it, so the
|
|
7
|
+
resource owner's password is re-sent at every expiry -- the cost §10.7 warns about, taken
|
|
8
|
+
deliberately (ADR-0019 decision 6). The refreshable tier is available to a generator that emits it;
|
|
9
|
+
this API's client does not."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Generic, TypeAlias
|
|
15
|
+
|
|
16
|
+
from pydantic import ConfigDict, Field
|
|
17
|
+
from typing_extensions import NotRequired, TypedDict
|
|
18
|
+
|
|
19
|
+
from ...bodies import form_body
|
|
20
|
+
from ...decoding import async_json_decoder, json_decoder
|
|
21
|
+
from ...params import UrlTemplate, param
|
|
22
|
+
from ...raw_client import AsyncRawClient, RawClient
|
|
23
|
+
from ..models import (
|
|
24
|
+
CredentialsPlacement,
|
|
25
|
+
OAuth2Credentials,
|
|
26
|
+
OAuthParams,
|
|
27
|
+
OAuthToken,
|
|
28
|
+
ScopeT,
|
|
29
|
+
oauth_error_response,
|
|
30
|
+
scope_params,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PasswordCredentials(OAuth2Credentials, Generic[ScopeT]):
|
|
35
|
+
"""The resource owner's credentials, plus the client's own (RFC 6749 §4.3.2).
|
|
36
|
+
|
|
37
|
+
``client_secret`` is optional because a public client has none -- §3.2.1 then makes ``client_id``
|
|
38
|
+
the required identification, which the placement function writes. ``scopes`` is typed by the
|
|
39
|
+
flow's declared vocabulary, as every grant's is."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
client_id: str
|
|
44
|
+
client_secret: str | None = Field(default=None, repr=False)
|
|
45
|
+
username: str
|
|
46
|
+
password: str = Field(repr=False)
|
|
47
|
+
scopes: list[ScopeT] | None = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def coerce(cls, value: PasswordCredentialsOrDict[ScopeT]) -> PasswordCredentials[ScopeT]:
|
|
51
|
+
"""Accept either spelling, validating the mapping form.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
value: The credential, as the typed model or as a mapping.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The validated model; see :meth:`BasicAuthCredentials.coerce` for why it takes no
|
|
58
|
+
``None``.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValidationError: If the mapping is missing a key, carries an unknown one, or names a
|
|
62
|
+
scope this flow does not declare."""
|
|
63
|
+
if isinstance(value, cls):
|
|
64
|
+
return value
|
|
65
|
+
return cls.model_validate(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PasswordCredentialsDict(TypedDict, Generic[ScopeT]):
|
|
69
|
+
client_id: str
|
|
70
|
+
client_secret: NotRequired[str | None]
|
|
71
|
+
username: str
|
|
72
|
+
password: str
|
|
73
|
+
scopes: NotRequired[list[ScopeT] | None]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
PasswordCredentialsOrDict: TypeAlias = PasswordCredentials[ScopeT] | PasswordCredentialsDict[ScopeT]
|
|
77
|
+
"""What a password credential accepts: the typed model, or a mapping of the same keys."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _token_params(credentials: PasswordCredentials[ScopeT]) -> OAuthParams:
|
|
81
|
+
"""RFC 6749 §4.3.2's token request, less the client's own half.
|
|
82
|
+
|
|
83
|
+
Shared by both flavours rather than duplicated, so an edit cannot land in one only.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
credentials: The configured password credentials.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The ``grant_type``, the resource owner's pair and the scopes. The client's half is
|
|
90
|
+
deliberately absent: it comes from the source's ``placement``."""
|
|
91
|
+
return (
|
|
92
|
+
param[str]("grant_type", "password"),
|
|
93
|
+
param[str]("username", credentials.username),
|
|
94
|
+
param[str]("password", credentials.password),
|
|
95
|
+
*scope_params(credentials.scopes),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
100
|
+
class PasswordTokenSource(Generic[ScopeT]):
|
|
101
|
+
"""The built-in :class:`TokenSource` for the password grant: POST the token request.
|
|
102
|
+
|
|
103
|
+
``placement`` is read from the flow's own ``x-oauth2-credentials-placement`` by the generated
|
|
104
|
+
client, exactly as the client-credentials source's is. Carries no ``auth_scheme=`` -- the
|
|
105
|
+
credentials are on this request already, so the fetch provably cannot recurse into itself."""
|
|
106
|
+
|
|
107
|
+
client: RawClient
|
|
108
|
+
token_url: UrlTemplate
|
|
109
|
+
placement: CredentialsPlacement
|
|
110
|
+
|
|
111
|
+
def fetch(self, credentials: PasswordCredentials[ScopeT]) -> OAuthToken:
|
|
112
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
113
|
+
return self.client.execute(
|
|
114
|
+
http_method="POST",
|
|
115
|
+
url_template=self.token_url,
|
|
116
|
+
headers=headers,
|
|
117
|
+
body=form_body(*_token_params(credentials), *client_params),
|
|
118
|
+
decoder=json_decoder[OAuthToken],
|
|
119
|
+
error_mapper=oauth_error_response,
|
|
120
|
+
).unwrap()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
124
|
+
class AsyncPasswordTokenSource(Generic[ScopeT]):
|
|
125
|
+
""":class:`PasswordTokenSource` for the asynchronous client: the request is awaited."""
|
|
126
|
+
|
|
127
|
+
client: AsyncRawClient
|
|
128
|
+
token_url: UrlTemplate
|
|
129
|
+
placement: CredentialsPlacement
|
|
130
|
+
|
|
131
|
+
async def fetch(self, credentials: PasswordCredentials[ScopeT]) -> OAuthToken:
|
|
132
|
+
headers, client_params = self.placement(credentials.client_id, credentials.client_secret)
|
|
133
|
+
return (
|
|
134
|
+
await self.client.execute(
|
|
135
|
+
http_method="POST",
|
|
136
|
+
url_template=self.token_url,
|
|
137
|
+
headers=headers,
|
|
138
|
+
body=form_body(*_token_params(credentials), *client_params),
|
|
139
|
+
decoder=async_json_decoder[OAuthToken],
|
|
140
|
+
error_mapper=oauth_error_response,
|
|
141
|
+
)
|
|
142
|
+
).unwrap()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Managed OAuth 2.0's caching schemes: two tiers, four classes, and no fetching code at all.
|
|
2
|
+
|
|
3
|
+
:class:`OAuth2Scheme` owns the token cache, the deadline, ``invalidate`` and the ``Bearer``
|
|
4
|
+
application, and obtains a token by calling ``source.fetch(credentials)``.
|
|
5
|
+
:class:`OAuth2RefreshableScheme` is its sibling for a grant whose endpoint may issue a refresh token,
|
|
6
|
+
and prefers a refresh over a re-acquisition. Two tiers and not one with a flag: client credentials can
|
|
7
|
+
never reach a refresh branch (RFC 6749 §4.4.3), so making that branch reachable would need an
|
|
8
|
+
``isinstance`` on the token.
|
|
9
|
+
|
|
10
|
+
The one module in this directory that names no grant, which is what lets four classes serve every one."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from math import inf
|
|
19
|
+
from typing import Final, Generic, TypeVar
|
|
20
|
+
|
|
21
|
+
from ...params import param
|
|
22
|
+
from ..models import AuthParams, OAuthToken, OAuthTokenRefreshable
|
|
23
|
+
from ..protocols import (
|
|
24
|
+
AsyncRefreshableTokenSource,
|
|
25
|
+
AsyncTokenSource,
|
|
26
|
+
RefreshableTokenSource,
|
|
27
|
+
TokenSource,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
CredentialsT = TypeVar("CredentialsT")
|
|
31
|
+
"""A grant's credentials, as the scheme holds them.
|
|
32
|
+
|
|
33
|
+
Declared here and not beside its contravariant twin in ``protocols``: the four classes below are its
|
|
34
|
+
only consumers."""
|
|
35
|
+
|
|
36
|
+
_EXPIRY_BUFFER_SECONDS: Final = 30.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _CachedToken:
|
|
41
|
+
"""An access token and the monotonic instant it stops being usable.
|
|
42
|
+
|
|
43
|
+
One object rather than two fields on the scheme, so the lock-free fast path reads a *single*
|
|
44
|
+
attribute and can never observe a new token beside a stale deadline."""
|
|
45
|
+
|
|
46
|
+
access_token: str = field(repr=False)
|
|
47
|
+
expires_at: float
|
|
48
|
+
|
|
49
|
+
def is_expired(self) -> bool:
|
|
50
|
+
return time.monotonic() >= self.expires_at
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _expires_at(expires_in: int | None) -> float:
|
|
54
|
+
"""The monotonic instant a token issued *now* stops being usable.
|
|
55
|
+
|
|
56
|
+
``time.monotonic``, because ``expires_in`` is a duration and a wall clock an NTP step can move
|
|
57
|
+
does not measure one. Named for the conversion it performs: a duration in, an instant out.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
expires_in: RFC 6749 §5.1's lifetime in seconds; ``None`` or non-positive means no
|
|
61
|
+
client-side deadline, and the token is cached until a 401 dislodges it.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The monotonic deadline, or ``inf`` when there is none."""
|
|
65
|
+
if expires_in is None or expires_in <= 0:
|
|
66
|
+
return inf
|
|
67
|
+
|
|
68
|
+
# ``max``, not a flat subtraction: subtracting outright is non-monotonic near the buffer, so a
|
|
69
|
+
# longer-lived token would be cached for less time. The two cross at 60 s.
|
|
70
|
+
return time.monotonic() + max(expires_in - _EXPIRY_BUFFER_SECONDS, expires_in / 2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _cache(token: OAuthToken) -> _CachedToken:
|
|
74
|
+
"""Deadline a token on the monotonic clock.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
token: The token endpoint's success body.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The cache entry: the token, plus the deadline :func:`_expires_at` computes for it."""
|
|
81
|
+
return _CachedToken(token.access_token, _expires_at(token.expires_in))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class _CachedRefreshableToken:
|
|
86
|
+
""":class:`_CachedToken` plus the refresh token that outlives it.
|
|
87
|
+
|
|
88
|
+
A sibling rather than a third field, so a scheme over a grant that cannot be issued a refresh
|
|
89
|
+
token has none to read. Frozen and fully built before publication, for its sibling's reason."""
|
|
90
|
+
|
|
91
|
+
access_token: str = field(repr=False)
|
|
92
|
+
expires_at: float
|
|
93
|
+
refresh_token: str | None = field(repr=False)
|
|
94
|
+
|
|
95
|
+
def is_expired(self) -> bool:
|
|
96
|
+
return time.monotonic() >= self.expires_at
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cache_refreshable(token: OAuthTokenRefreshable, previous: str | None) -> _CachedRefreshableToken:
|
|
100
|
+
"""Deadline a refreshable token, keeping ``previous`` when the response omitted one.
|
|
101
|
+
|
|
102
|
+
RFC 6749 §6: a refresh response MAY omit ``refresh_token``, and the previously-issued one stays
|
|
103
|
+
valid when it does.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
token: The token endpoint's success body.
|
|
107
|
+
previous: The refresh token that bought this response; ``None`` on a first acquisition.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The cache entry, carrying whichever refresh token is still valid."""
|
|
111
|
+
return _CachedRefreshableToken(
|
|
112
|
+
token.access_token,
|
|
113
|
+
_expires_at(token.expires_in),
|
|
114
|
+
token.refresh_token if token.refresh_token is not None else previous,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class OAuth2Scheme(Generic[CredentialsT]):
|
|
119
|
+
"""A bearer token obtained through a :class:`TokenSource` and cached until it expires.
|
|
120
|
+
|
|
121
|
+
Grant-agnostic, and holds no fetching code of its own: it calls ``source.fetch(credentials)``.
|
|
122
|
+
Acquisition is lazy and one instance serves a client's whole life, so the cache does too. Not
|
|
123
|
+
frozen -- the token cache is the one thing a scheme legitimately mutates."""
|
|
124
|
+
|
|
125
|
+
__slots__ = ("_cached", "_credentials", "_lock", "_source")
|
|
126
|
+
|
|
127
|
+
def __init__(self, *, credentials: CredentialsT, source: TokenSource[CredentialsT]) -> None:
|
|
128
|
+
self._credentials = credentials
|
|
129
|
+
self._source = source
|
|
130
|
+
self._lock = threading.Lock()
|
|
131
|
+
self._cached: _CachedToken | None = None
|
|
132
|
+
|
|
133
|
+
def apply(self) -> AuthParams:
|
|
134
|
+
token = self._get_and_cache_token()
|
|
135
|
+
return AuthParams(headers=(param[str]("Authorization", f"Bearer {token}"),))
|
|
136
|
+
|
|
137
|
+
def invalidate(self) -> None:
|
|
138
|
+
"""Drop the cached token, so the next request obtains a new one."""
|
|
139
|
+
self._cached = None
|
|
140
|
+
|
|
141
|
+
def _get_and_cache_token(self) -> str:
|
|
142
|
+
"""The cached access token, obtaining and caching one when none is fresh.
|
|
143
|
+
|
|
144
|
+
Double-checked: the fast path is one lock-free attribute read, and the second check inside
|
|
145
|
+
the lock is what makes a burst of concurrent requests pay for **one** acquisition.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The access token to apply."""
|
|
149
|
+
cached = self._cached
|
|
150
|
+
if cached is not None and not cached.is_expired():
|
|
151
|
+
return cached.access_token
|
|
152
|
+
|
|
153
|
+
with self._lock:
|
|
154
|
+
cached = self._cached
|
|
155
|
+
if cached is not None and not cached.is_expired():
|
|
156
|
+
return cached.access_token
|
|
157
|
+
|
|
158
|
+
cached = _cache(self._source.fetch(self._credentials))
|
|
159
|
+
self._cached = cached
|
|
160
|
+
return cached.access_token
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class AsyncOAuth2Scheme(Generic[CredentialsT]):
|
|
164
|
+
""":class:`OAuth2Scheme` for the asynchronous client: the token is awaited.
|
|
165
|
+
|
|
166
|
+
The lock is an ``asyncio.Lock``, which binds to the running loop on first *use* rather than at
|
|
167
|
+
construction, so building the client outside a loop is fine. Thereafter the scheme belongs to
|
|
168
|
+
one loop -- the same constraint the connection pool beside it already imposes."""
|
|
169
|
+
|
|
170
|
+
__slots__ = ("_cached", "_credentials", "_lock", "_source")
|
|
171
|
+
|
|
172
|
+
def __init__(self, *, credentials: CredentialsT, source: AsyncTokenSource[CredentialsT]) -> None:
|
|
173
|
+
self._credentials = credentials
|
|
174
|
+
self._source = source
|
|
175
|
+
self._lock = asyncio.Lock()
|
|
176
|
+
self._cached: _CachedToken | None = None
|
|
177
|
+
|
|
178
|
+
async def apply(self) -> AuthParams:
|
|
179
|
+
token = await self._get_and_cache_token()
|
|
180
|
+
return AuthParams(headers=(param[str]("Authorization", f"Bearer {token}"),))
|
|
181
|
+
|
|
182
|
+
def invalidate(self) -> None:
|
|
183
|
+
"""Drop the cached token, so the next request obtains a new one."""
|
|
184
|
+
self._cached = None
|
|
185
|
+
|
|
186
|
+
async def _get_and_cache_token(self) -> str:
|
|
187
|
+
"""See :meth:`OAuth2Scheme._get_and_cache_token`; the source may need to be awaited.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
The access token to apply."""
|
|
191
|
+
cached = self._cached
|
|
192
|
+
if cached is not None and not cached.is_expired():
|
|
193
|
+
return cached.access_token
|
|
194
|
+
|
|
195
|
+
async with self._lock:
|
|
196
|
+
cached = self._cached
|
|
197
|
+
if cached is not None and not cached.is_expired():
|
|
198
|
+
return cached.access_token
|
|
199
|
+
|
|
200
|
+
cached = _cache(await self._source.fetch(self._credentials))
|
|
201
|
+
self._cached = cached
|
|
202
|
+
return cached.access_token
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class OAuth2RefreshableScheme(Generic[CredentialsT]):
|
|
206
|
+
""":class:`OAuth2Scheme` for a grant whose token endpoint may issue a refresh token.
|
|
207
|
+
|
|
208
|
+
**Refreshing is preferred over re-acquiring**, which is the whole value of the tier: for the
|
|
209
|
+
authorization-code grant a re-acquisition runs the caller's prompt again. :meth:`invalidate`
|
|
210
|
+
drops the refresh token too, so a 401 is answered by a full re-acquisition."""
|
|
211
|
+
|
|
212
|
+
__slots__ = ("_cached", "_credentials", "_lock", "_source")
|
|
213
|
+
|
|
214
|
+
def __init__(self, *, credentials: CredentialsT, source: RefreshableTokenSource[CredentialsT]) -> None:
|
|
215
|
+
self._credentials = credentials
|
|
216
|
+
self._source = source
|
|
217
|
+
self._lock = threading.Lock()
|
|
218
|
+
self._cached: _CachedRefreshableToken | None = None
|
|
219
|
+
|
|
220
|
+
def apply(self) -> AuthParams:
|
|
221
|
+
token = self._get_and_cache_token()
|
|
222
|
+
return AuthParams(headers=(param[str]("Authorization", f"Bearer {token}"),))
|
|
223
|
+
|
|
224
|
+
def invalidate(self) -> None:
|
|
225
|
+
"""Drop the cached token, so the next request acquires a new one from scratch."""
|
|
226
|
+
self._cached = None
|
|
227
|
+
|
|
228
|
+
def _get_and_cache_token(self) -> str:
|
|
229
|
+
"""The cached access token, refreshing or re-acquiring when none is fresh.
|
|
230
|
+
|
|
231
|
+
Double-checked as :meth:`OAuth2Scheme._get_and_cache_token` is, with one branch added: a
|
|
232
|
+
stale entry still carrying a refresh token is worth one attempt first. The fallback calls
|
|
233
|
+
``fetch``, which for an interactive grant blocks on a human *while this lock is held*.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
The access token to apply."""
|
|
237
|
+
cached = self._cached
|
|
238
|
+
if cached is not None and not cached.is_expired():
|
|
239
|
+
return cached.access_token
|
|
240
|
+
|
|
241
|
+
with self._lock:
|
|
242
|
+
cached = self._cached
|
|
243
|
+
if cached is not None and not cached.is_expired():
|
|
244
|
+
return cached.access_token
|
|
245
|
+
|
|
246
|
+
if cached is not None and cached.refresh_token is not None:
|
|
247
|
+
refreshed = self._source.refresh(self._credentials, cached.refresh_token)
|
|
248
|
+
if refreshed is not None:
|
|
249
|
+
acquired = _cache_refreshable(refreshed, cached.refresh_token)
|
|
250
|
+
self._cached = acquired
|
|
251
|
+
return acquired.access_token
|
|
252
|
+
|
|
253
|
+
acquired = _cache_refreshable(self._source.fetch(self._credentials), None)
|
|
254
|
+
self._cached = acquired
|
|
255
|
+
return acquired.access_token
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AsyncOAuth2RefreshableScheme(Generic[CredentialsT]):
|
|
259
|
+
""":class:`OAuth2RefreshableScheme` for the asynchronous client: both source calls are awaited."""
|
|
260
|
+
|
|
261
|
+
__slots__ = ("_cached", "_credentials", "_lock", "_source")
|
|
262
|
+
|
|
263
|
+
def __init__(self, *, credentials: CredentialsT, source: AsyncRefreshableTokenSource[CredentialsT]) -> None:
|
|
264
|
+
self._credentials = credentials
|
|
265
|
+
self._source = source
|
|
266
|
+
self._lock = asyncio.Lock()
|
|
267
|
+
self._cached: _CachedRefreshableToken | None = None
|
|
268
|
+
|
|
269
|
+
async def apply(self) -> AuthParams:
|
|
270
|
+
token = await self._get_and_cache_token()
|
|
271
|
+
return AuthParams(headers=(param[str]("Authorization", f"Bearer {token}"),))
|
|
272
|
+
|
|
273
|
+
def invalidate(self) -> None:
|
|
274
|
+
"""Drop the cached token, so the next request acquires a new one from scratch."""
|
|
275
|
+
self._cached = None
|
|
276
|
+
|
|
277
|
+
async def _get_and_cache_token(self) -> str:
|
|
278
|
+
"""See :meth:`OAuth2RefreshableScheme._get_and_cache_token`; both source calls are awaited.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
The access token to apply."""
|
|
282
|
+
cached = self._cached
|
|
283
|
+
if cached is not None and not cached.is_expired():
|
|
284
|
+
return cached.access_token
|
|
285
|
+
|
|
286
|
+
async with self._lock:
|
|
287
|
+
cached = self._cached
|
|
288
|
+
if cached is not None and not cached.is_expired():
|
|
289
|
+
return cached.access_token
|
|
290
|
+
|
|
291
|
+
if cached is not None and cached.refresh_token is not None:
|
|
292
|
+
refreshed = await self._source.refresh(self._credentials, cached.refresh_token)
|
|
293
|
+
if refreshed is not None:
|
|
294
|
+
acquired = _cache_refreshable(refreshed, cached.refresh_token)
|
|
295
|
+
self._cached = acquired
|
|
296
|
+
return acquired.access_token
|
|
297
|
+
|
|
298
|
+
acquired = _cache_refreshable(await self._source.fetch(self._credentials), None)
|
|
299
|
+
self._cached = acquired
|
|
300
|
+
return acquired.access_token
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""What the generated per-group raw-response classes share: a raw client, a server, its auth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from .raw_client import RawClientT
|
|
8
|
+
|
|
9
|
+
ServerT = TypeVar("ServerT")
|
|
10
|
+
"""The generated ``Server`` type a subclass carries.
|
|
11
|
+
|
|
12
|
+
Unbounded and never touched by the runtime: its only purpose is to let this class hold the server
|
|
13
|
+
without *naming* it, since the runtime is copied verbatim into every generated SDK. A plain
|
|
14
|
+
attribute typed ``Any`` would lose what subclasses need -- that ``self._server.prism(...)``
|
|
15
|
+
type-checks."""
|
|
16
|
+
|
|
17
|
+
AuthT = TypeVar("AuthT")
|
|
18
|
+
"""The generated scheme holder a *secured* subclass carries.
|
|
19
|
+
|
|
20
|
+
Unbounded for the same reason as ``ServerT``, so ``self._auth.basic_auth`` type-checks in a
|
|
21
|
+
generated endpoint while the runtime stays free of any upward import. It is also what keeps the two
|
|
22
|
+
flavors' schemes apart, so a scheme that can only work on one transport cannot be handed to the
|
|
23
|
+
other."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BaseRawResponse(Generic[RawClientT, ServerT]):
|
|
27
|
+
"""Base for the internal per-group raw-response classes.
|
|
28
|
+
|
|
29
|
+
Holds the raw client and the server, both type parameters a subclass fixes, so
|
|
30
|
+
``self._client.execute(...)`` resolves to the right flavor and ``self._server`` to the concrete
|
|
31
|
+
generated ``Server``. A group whose operations declare no ``security`` extends **this**, and so
|
|
32
|
+
has no ``self._auth`` to name; one that does extends :class:`SecuredRawResponse`."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: RawClientT, server: ServerT) -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
self._server = server
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SecuredRawResponse(BaseRawResponse[RawClientT, ServerT], Generic[RawClientT, ServerT, AuthT]):
|
|
40
|
+
"""Base for a group at least one of whose operations declares ``security``.
|
|
41
|
+
|
|
42
|
+
Adds the API's security schemes as a third parameter a subclass fixes. Two classes rather than
|
|
43
|
+
one optional argument, because the split makes the reach of a credential checkable: a group that
|
|
44
|
+
declares no ``security`` cannot name ``self._auth``, so a generator writing ``auth=self._auth.``
|
|
45
|
+
into the wrong file fails to type-check instead of shipping a credential."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, client: RawClientT, server: ServerT, auth: AuthT) -> None:
|
|
48
|
+
super().__init__(client, server)
|
|
49
|
+
self._auth = auth
|