otf-api 0.2.2__py3-none-any.whl → 0.4.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.
- otf_api/__init__.py +14 -69
- otf_api/api.py +873 -66
- otf_api/auth.py +314 -0
- otf_api/cli/__init__.py +4 -0
- otf_api/cli/_utilities.py +60 -0
- otf_api/cli/app.py +172 -0
- otf_api/cli/bookings.py +231 -0
- otf_api/cli/prompts.py +162 -0
- otf_api/models/__init__.py +30 -23
- otf_api/models/base.py +205 -2
- otf_api/models/responses/__init__.py +29 -29
- otf_api/models/responses/body_composition_list.py +304 -0
- otf_api/models/responses/book_class.py +405 -0
- otf_api/models/responses/bookings.py +211 -37
- otf_api/models/responses/cancel_booking.py +93 -0
- otf_api/models/responses/challenge_tracker_content.py +6 -6
- otf_api/models/responses/challenge_tracker_detail.py +6 -6
- otf_api/models/responses/classes.py +205 -7
- otf_api/models/responses/enums.py +0 -35
- otf_api/models/responses/favorite_studios.py +5 -5
- otf_api/models/responses/latest_agreement.py +2 -2
- otf_api/models/responses/lifetime_stats.py +92 -0
- otf_api/models/responses/member_detail.py +17 -12
- otf_api/models/responses/member_membership.py +2 -2
- otf_api/models/responses/member_purchases.py +9 -9
- otf_api/models/responses/out_of_studio_workout_history.py +4 -4
- otf_api/models/responses/performance_summary_detail.py +1 -1
- otf_api/models/responses/performance_summary_list.py +13 -13
- otf_api/models/responses/studio_detail.py +10 -10
- otf_api/models/responses/studio_services.py +8 -8
- otf_api/models/responses/telemetry.py +6 -6
- otf_api/models/responses/telemetry_hr_history.py +6 -6
- otf_api/models/responses/telemetry_max_hr.py +3 -3
- otf_api/models/responses/total_classes.py +2 -2
- otf_api/models/responses/workouts.py +4 -4
- otf_api-0.4.0.dist-info/METADATA +54 -0
- otf_api-0.4.0.dist-info/RECORD +42 -0
- otf_api-0.4.0.dist-info/entry_points.txt +3 -0
- otf_api/__version__.py +0 -1
- otf_api/classes_api.py +0 -44
- otf_api/member_api.py +0 -380
- otf_api/models/auth.py +0 -141
- otf_api/performance_api.py +0 -54
- otf_api/studios_api.py +0 -96
- otf_api/telemetry_api.py +0 -95
- otf_api-0.2.2.dist-info/METADATA +0 -284
- otf_api-0.2.2.dist-info/RECORD +0 -38
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
otf_api/auth.py
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
import typing
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
import jwt
|
5
|
+
import pendulum
|
6
|
+
from loguru import logger
|
7
|
+
from pycognito import AWSSRP, Cognito, MFAChallengeException
|
8
|
+
from pycognito.exceptions import TokenVerificationException
|
9
|
+
from pydantic import Field
|
10
|
+
from pydantic.config import ConfigDict
|
11
|
+
|
12
|
+
from otf_api.models.base import OtfItemBase
|
13
|
+
|
14
|
+
if typing.TYPE_CHECKING:
|
15
|
+
from boto3.session import Session
|
16
|
+
from botocore.config import Config
|
17
|
+
|
18
|
+
CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
|
19
|
+
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
20
|
+
|
21
|
+
|
22
|
+
class OtfCognito(Cognito):
|
23
|
+
_device_key: str | None = None
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
user_pool_id: str,
|
28
|
+
client_id: str,
|
29
|
+
user_pool_region: str | None = None,
|
30
|
+
username: str | None = None,
|
31
|
+
id_token: str | None = None,
|
32
|
+
refresh_token: str | None = None,
|
33
|
+
access_token: str | None = None,
|
34
|
+
client_secret: str | None = None,
|
35
|
+
access_key: str | None = None,
|
36
|
+
secret_key: str | None = None,
|
37
|
+
session: "Session|None" = None,
|
38
|
+
botocore_config: "Config|None" = None,
|
39
|
+
boto3_client_kwargs: dict[str, Any] | None = None,
|
40
|
+
device_key: str | None = None,
|
41
|
+
):
|
42
|
+
super().__init__(
|
43
|
+
user_pool_id,
|
44
|
+
client_id,
|
45
|
+
user_pool_region=user_pool_region,
|
46
|
+
username=username,
|
47
|
+
id_token=id_token,
|
48
|
+
refresh_token=refresh_token,
|
49
|
+
access_token=access_token,
|
50
|
+
client_secret=client_secret,
|
51
|
+
access_key=access_key,
|
52
|
+
secret_key=secret_key,
|
53
|
+
session=session,
|
54
|
+
botocore_config=botocore_config,
|
55
|
+
boto3_client_kwargs=boto3_client_kwargs,
|
56
|
+
)
|
57
|
+
self.device_key = device_key
|
58
|
+
|
59
|
+
@property
|
60
|
+
def device_key(self) -> str | None:
|
61
|
+
return self._device_key
|
62
|
+
|
63
|
+
@device_key.setter
|
64
|
+
def device_key(self, value: str | None):
|
65
|
+
if not value:
|
66
|
+
logger.info("Clearing device key")
|
67
|
+
self._device_key = value
|
68
|
+
return
|
69
|
+
|
70
|
+
redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:]
|
71
|
+
logger.info(f"Setting device key: {redacted_value}")
|
72
|
+
self._device_key = value
|
73
|
+
|
74
|
+
def _set_tokens(self, tokens: dict[str, Any]):
|
75
|
+
"""Set the tokens and device metadata from the response.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
tokens (dict): The response from the Cognito service.
|
79
|
+
"""
|
80
|
+
super()._set_tokens(tokens)
|
81
|
+
|
82
|
+
if new_metadata := tokens["AuthenticationResult"].get("NewDeviceMetadata"):
|
83
|
+
self.device_key = new_metadata["DeviceKey"]
|
84
|
+
|
85
|
+
def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None, device_key: str | None = None):
|
86
|
+
"""
|
87
|
+
Authenticate the user using the SRP protocol. Overridden to add `confirm_device` call.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
password (str): The user's password
|
91
|
+
client_metadata (dict, optional): Any additional client metadata to send to Cognito
|
92
|
+
"""
|
93
|
+
aws = AWSSRP(
|
94
|
+
username=self.username,
|
95
|
+
password=password,
|
96
|
+
pool_id=self.user_pool_id,
|
97
|
+
client_id=self.client_id,
|
98
|
+
client=self.client,
|
99
|
+
client_secret=self.client_secret,
|
100
|
+
)
|
101
|
+
try:
|
102
|
+
tokens = aws.authenticate_user(client_metadata=client_metadata)
|
103
|
+
except MFAChallengeException as mfa_challenge:
|
104
|
+
self.mfa_tokens = mfa_challenge.get_tokens()
|
105
|
+
raise mfa_challenge
|
106
|
+
|
107
|
+
# Set the tokens and device metadata
|
108
|
+
self._set_tokens(tokens)
|
109
|
+
|
110
|
+
if not device_key:
|
111
|
+
# Confirm the device so we can use the refresh token
|
112
|
+
aws.confirm_device(tokens)
|
113
|
+
else:
|
114
|
+
self.device_key = device_key
|
115
|
+
try:
|
116
|
+
self.renew_access_token()
|
117
|
+
except TokenVerificationException:
|
118
|
+
logger.error("Failed to renew access token. Confirming device.")
|
119
|
+
self.device_key = None
|
120
|
+
aws.confirm_device(tokens)
|
121
|
+
|
122
|
+
def check_token(self, renew: bool = True) -> bool:
|
123
|
+
"""
|
124
|
+
Checks the exp attribute of the access_token and either refreshes
|
125
|
+
the tokens by calling the renew_access_tokens method or does nothing
|
126
|
+
:param renew: bool indicating whether to refresh on expiration
|
127
|
+
:return: bool indicating whether access_token has expired
|
128
|
+
"""
|
129
|
+
if not self.access_token:
|
130
|
+
raise AttributeError("Access Token Required to Check Token")
|
131
|
+
now = pendulum.now()
|
132
|
+
dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False})
|
133
|
+
|
134
|
+
exp = pendulum.DateTime.fromtimestamp(dec_access_token["exp"])
|
135
|
+
if now > exp.subtract(minutes=15):
|
136
|
+
expired = True
|
137
|
+
if renew:
|
138
|
+
self.renew_access_token()
|
139
|
+
else:
|
140
|
+
expired = False
|
141
|
+
return expired
|
142
|
+
|
143
|
+
def renew_access_token(self):
|
144
|
+
"""Sets a new access token on the User using the cached refresh token and device metadata."""
|
145
|
+
auth_params = {"REFRESH_TOKEN": self.refresh_token}
|
146
|
+
self._add_secret_hash(auth_params, "SECRET_HASH")
|
147
|
+
|
148
|
+
if self.device_key:
|
149
|
+
logger.info("Using device key for refresh token")
|
150
|
+
auth_params["DEVICE_KEY"] = self.device_key
|
151
|
+
|
152
|
+
refresh_response = self.client.initiate_auth(
|
153
|
+
ClientId=self.client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params
|
154
|
+
)
|
155
|
+
self._set_tokens(refresh_response)
|
156
|
+
|
157
|
+
@classmethod
|
158
|
+
def from_token(
|
159
|
+
cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None
|
160
|
+
) -> "OtfCognito":
|
161
|
+
"""Create an OtfCognito instance from an id token.
|
162
|
+
|
163
|
+
Args:
|
164
|
+
access_token (str): The access token.
|
165
|
+
id_token (str): The id token.
|
166
|
+
refresh_token (str, optional): The refresh token. Defaults to None.
|
167
|
+
device_key (str, optional): The device key. Defaults
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
OtfCognito: The user instance
|
171
|
+
"""
|
172
|
+
cognito = OtfCognito(
|
173
|
+
USER_POOL_ID,
|
174
|
+
CLIENT_ID,
|
175
|
+
access_token=access_token,
|
176
|
+
id_token=id_token,
|
177
|
+
refresh_token=refresh_token,
|
178
|
+
device_key=device_key,
|
179
|
+
)
|
180
|
+
cognito.verify_tokens()
|
181
|
+
cognito.check_token()
|
182
|
+
return cognito
|
183
|
+
|
184
|
+
@classmethod
|
185
|
+
def login(cls, username: str, password: str) -> "OtfCognito":
|
186
|
+
"""Create an OtfCognito instance from a username and password.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
username (str): The username to login with.
|
190
|
+
password (str): The password to login with.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
OtfCognito: The logged in user.
|
194
|
+
"""
|
195
|
+
cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username)
|
196
|
+
cognito_user.authenticate(password)
|
197
|
+
cognito_user.check_token()
|
198
|
+
return cognito_user
|
199
|
+
|
200
|
+
|
201
|
+
class IdClaimsData(OtfItemBase):
|
202
|
+
sub: str
|
203
|
+
email_verified: bool
|
204
|
+
iss: str
|
205
|
+
cognito_username: str = Field(alias="cognito:username")
|
206
|
+
given_name: str
|
207
|
+
locale: str
|
208
|
+
home_studio_id: str = Field(alias="custom:home_studio_id")
|
209
|
+
aud: str
|
210
|
+
event_id: str
|
211
|
+
token_use: str
|
212
|
+
auth_time: int
|
213
|
+
exp: int
|
214
|
+
is_migration: str = Field(alias="custom:isMigration")
|
215
|
+
iat: int
|
216
|
+
family_name: str
|
217
|
+
email: str
|
218
|
+
koji_person_id: str = Field(alias="custom:koji_person_id")
|
219
|
+
|
220
|
+
@property
|
221
|
+
def member_uuid(self) -> str:
|
222
|
+
return self.cognito_username
|
223
|
+
|
224
|
+
@property
|
225
|
+
def full_name(self) -> str:
|
226
|
+
return f"{self.given_name} {self.family_name}"
|
227
|
+
|
228
|
+
|
229
|
+
class AccessClaimsData(OtfItemBase):
|
230
|
+
sub: str
|
231
|
+
device_key: str
|
232
|
+
iss: str
|
233
|
+
client_id: str
|
234
|
+
event_id: str
|
235
|
+
token_use: str
|
236
|
+
scope: str
|
237
|
+
auth_time: int
|
238
|
+
exp: int
|
239
|
+
iat: int
|
240
|
+
jti: str
|
241
|
+
username: str
|
242
|
+
|
243
|
+
@property
|
244
|
+
def member_uuid(self) -> str:
|
245
|
+
return self.username
|
246
|
+
|
247
|
+
|
248
|
+
class OtfUser(OtfItemBase):
|
249
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
250
|
+
cognito: OtfCognito
|
251
|
+
|
252
|
+
def __init__(
|
253
|
+
self,
|
254
|
+
username: str | None = None,
|
255
|
+
password: str | None = None,
|
256
|
+
id_token: str | None = None,
|
257
|
+
access_token: str | None = None,
|
258
|
+
refresh_token: str | None = None,
|
259
|
+
device_key: str | None = None,
|
260
|
+
cognito: OtfCognito | None = None,
|
261
|
+
):
|
262
|
+
"""Create a User instance.
|
263
|
+
|
264
|
+
Args:
|
265
|
+
username (str, optional): The username to login with. Defaults to None.
|
266
|
+
password (str, optional): The password to login with. Defaults to None.
|
267
|
+
id_token (str, optional): The id token. Defaults to None.
|
268
|
+
access_token (str, optional): The access token. Defaults to None.
|
269
|
+
refresh_token (str, optional): The refresh token. Defaults to None.
|
270
|
+
device_key (str, optional): The device key. Defaults to None.
|
271
|
+
cognito (OtfCognito, optional): A Cognito instance. Defaults to None.
|
272
|
+
|
273
|
+
Raises:
|
274
|
+
ValueError: Must provide either username and password or id token
|
275
|
+
|
276
|
+
|
277
|
+
"""
|
278
|
+
if cognito:
|
279
|
+
cognito = cognito
|
280
|
+
elif username and password:
|
281
|
+
cognito = OtfCognito.login(username, password)
|
282
|
+
elif access_token and id_token:
|
283
|
+
cognito = OtfCognito.from_token(access_token, id_token, refresh_token, device_key)
|
284
|
+
else:
|
285
|
+
raise ValueError("Must provide either username and password or id token.")
|
286
|
+
|
287
|
+
super().__init__(cognito=cognito)
|
288
|
+
|
289
|
+
@property
|
290
|
+
def member_id(self) -> str:
|
291
|
+
return self.id_claims_data.cognito_username
|
292
|
+
|
293
|
+
@property
|
294
|
+
def member_uuid(self) -> str:
|
295
|
+
return self.access_claims_data.sub
|
296
|
+
|
297
|
+
@property
|
298
|
+
def access_claims_data(self) -> AccessClaimsData:
|
299
|
+
return AccessClaimsData(**self.cognito.access_claims)
|
300
|
+
|
301
|
+
@property
|
302
|
+
def id_claims_data(self) -> IdClaimsData:
|
303
|
+
return IdClaimsData(**self.cognito.id_claims)
|
304
|
+
|
305
|
+
def get_tokens(self) -> dict[str, str]:
|
306
|
+
return {
|
307
|
+
"id_token": self.cognito.id_token,
|
308
|
+
"access_token": self.cognito.access_token,
|
309
|
+
"refresh_token": self.cognito.refresh_token,
|
310
|
+
}
|
311
|
+
|
312
|
+
@property
|
313
|
+
def device_key(self) -> str:
|
314
|
+
return self.cognito.device_key
|
otf_api/cli/__init__.py
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
import functools
|
2
|
+
import inspect
|
3
|
+
import traceback
|
4
|
+
from collections.abc import Awaitable, Callable
|
5
|
+
from typing import Any, NoReturn, ParamSpec, TypeGuard, TypeVar
|
6
|
+
|
7
|
+
import typer
|
8
|
+
from click.exceptions import ClickException
|
9
|
+
|
10
|
+
T = TypeVar("T")
|
11
|
+
P = ParamSpec("P")
|
12
|
+
R = TypeVar("R")
|
13
|
+
|
14
|
+
|
15
|
+
def is_async_fn(func: Callable[P, R] | Callable[P, Awaitable[R]]) -> TypeGuard[Callable[P, Awaitable[R]]]:
|
16
|
+
"""
|
17
|
+
Returns `True` if a function returns a coroutine.
|
18
|
+
|
19
|
+
See https://github.com/microsoft/pyright/issues/2142 for an example use
|
20
|
+
"""
|
21
|
+
while hasattr(func, "__wrapped__"):
|
22
|
+
func = func.__wrapped__
|
23
|
+
|
24
|
+
return inspect.iscoroutinefunction(func)
|
25
|
+
|
26
|
+
|
27
|
+
def exit_with_error(message: Any, code: int = 1, **kwargs: Any) -> NoReturn:
|
28
|
+
"""
|
29
|
+
Utility to print a stylized error message and exit with a non-zero code
|
30
|
+
"""
|
31
|
+
from otf_api.cli.app import base_app
|
32
|
+
|
33
|
+
kwargs.setdefault("style", "red")
|
34
|
+
base_app.console.print(message, **kwargs)
|
35
|
+
raise typer.Exit(code)
|
36
|
+
|
37
|
+
|
38
|
+
def exit_with_success(message: Any, **kwargs: Any) -> NoReturn:
|
39
|
+
"""
|
40
|
+
Utility to print a stylized success message and exit with a zero code
|
41
|
+
"""
|
42
|
+
from otf_api.cli.app import base_app
|
43
|
+
|
44
|
+
kwargs.setdefault("style", "green")
|
45
|
+
base_app.console.print(message, **kwargs)
|
46
|
+
raise typer.Exit(0)
|
47
|
+
|
48
|
+
|
49
|
+
def with_cli_exception_handling(fn: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
50
|
+
@functools.wraps(fn)
|
51
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any | None:
|
52
|
+
try:
|
53
|
+
return fn(*args, **kwargs)
|
54
|
+
except (typer.Exit, typer.Abort, ClickException):
|
55
|
+
raise # Do not capture click or typer exceptions
|
56
|
+
except Exception:
|
57
|
+
traceback.print_exc()
|
58
|
+
exit_with_error("An exception occurred.")
|
59
|
+
|
60
|
+
return wrapper
|
otf_api/cli/app.py
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
import asyncio
|
2
|
+
import functools
|
3
|
+
import sys
|
4
|
+
import typing
|
5
|
+
from collections.abc import Callable
|
6
|
+
from enum import Enum
|
7
|
+
|
8
|
+
import typer
|
9
|
+
from loguru import logger
|
10
|
+
from rich.console import Console
|
11
|
+
from rich.theme import Theme
|
12
|
+
|
13
|
+
import otf_api
|
14
|
+
from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling
|
15
|
+
|
16
|
+
if typing.TYPE_CHECKING:
|
17
|
+
from otf_api import Otf
|
18
|
+
|
19
|
+
|
20
|
+
class OutputType(str, Enum):
|
21
|
+
json = "json"
|
22
|
+
table = "table"
|
23
|
+
interactive = "interactive"
|
24
|
+
|
25
|
+
|
26
|
+
def version_callback(value: bool) -> None:
|
27
|
+
if value:
|
28
|
+
print(otf_api.__version__)
|
29
|
+
raise typer.Exit()
|
30
|
+
|
31
|
+
|
32
|
+
OPT_USERNAME: str = typer.Option(None, envvar=["OTF_EMAIL", "OTF_USERNAME"], help="Username for the OTF API")
|
33
|
+
OPT_PASSWORD: str = typer.Option(envvar="OTF_PASSWORD", help="Password for the OTF API", hide_input=True)
|
34
|
+
OPT_OUTPUT: OutputType = typer.Option(None, envvar="OTF_OUTPUT", show_default=False, help="Output format")
|
35
|
+
OPT_LOG_LEVEL: str = typer.Option("CRITICAL", help="Log level", envvar="OTF_LOG_LEVEL")
|
36
|
+
OPT_VERSION: bool = typer.Option(
|
37
|
+
None, "--version", callback=version_callback, help="Display the current version.", is_eager=True
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
def register_main_callback(app: "AsyncTyper") -> None:
|
42
|
+
@app.callback() # type: ignore
|
43
|
+
@with_cli_exception_handling
|
44
|
+
def main_callback(
|
45
|
+
ctx: typer.Context, # noqa
|
46
|
+
version: bool = OPT_VERSION, # noqa
|
47
|
+
username: str = OPT_USERNAME,
|
48
|
+
password: str = OPT_PASSWORD,
|
49
|
+
output: OutputType = OPT_OUTPUT,
|
50
|
+
log_level: str = OPT_LOG_LEVEL,
|
51
|
+
) -> None:
|
52
|
+
app.setup_console()
|
53
|
+
app.set_username(username)
|
54
|
+
app.password = password
|
55
|
+
app.output = output or OutputType.table
|
56
|
+
app.set_log_level(log_level)
|
57
|
+
|
58
|
+
# When running on Windows we need to ensure that the correct event loop policy is
|
59
|
+
# in place or we will not be able to spawn subprocesses. Sometimes this policy is
|
60
|
+
# changed by other libraries, but here in our CLI we should have ownership of the
|
61
|
+
# process and be able to safely force it to be the correct policy.
|
62
|
+
# https://github.com/PrefectHQ/prefect/issues/8206
|
63
|
+
if sys.platform == "win32":
|
64
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
65
|
+
|
66
|
+
|
67
|
+
class AsyncTyper(typer.Typer):
|
68
|
+
"""
|
69
|
+
Wraps commands created by `Typer` to support async functions and handle errors.
|
70
|
+
"""
|
71
|
+
|
72
|
+
console: Console
|
73
|
+
|
74
|
+
def __init__(self, *args: typing.Any, **kwargs: typing.Any):
|
75
|
+
super().__init__(*args, **kwargs)
|
76
|
+
|
77
|
+
theme = Theme({"prompt.choices": "bold blue"})
|
78
|
+
self.console = Console(highlight=False, theme=theme, color_system="auto")
|
79
|
+
|
80
|
+
# TODO: clean these up later, just don't want warnings everywhere that these could be None
|
81
|
+
self.api: Otf = None # type: ignore
|
82
|
+
self.username: str = None # type: ignore
|
83
|
+
self.password: str = None # type: ignore
|
84
|
+
self.output: OutputType = None # type: ignore
|
85
|
+
self.logger = logger
|
86
|
+
self.log_level = "CRITICAL"
|
87
|
+
|
88
|
+
def set_username(self, username: str | None = None) -> None:
|
89
|
+
if username:
|
90
|
+
self.username = username
|
91
|
+
return
|
92
|
+
|
93
|
+
raise ValueError("Username not provided and not found in cache")
|
94
|
+
|
95
|
+
def set_log_level(self, level: str) -> None:
|
96
|
+
self.log_level = level
|
97
|
+
logger.remove()
|
98
|
+
logger.add(sys.stderr, level=self.log_level.upper())
|
99
|
+
|
100
|
+
def print(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
101
|
+
if self.output == "json":
|
102
|
+
self.console.print_json(*args, **kwargs)
|
103
|
+
else:
|
104
|
+
self.console.print(*args, **kwargs)
|
105
|
+
|
106
|
+
def add_typer(
|
107
|
+
self, typer_instance: "AsyncTyper", *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
|
108
|
+
) -> typing.Any:
|
109
|
+
aliases = aliases or []
|
110
|
+
for alias in aliases:
|
111
|
+
super().add_typer(typer_instance, *args, name=alias, no_args_is_help=True, hidden=True, **kwargs)
|
112
|
+
|
113
|
+
return super().add_typer(typer_instance, *args, no_args_is_help=True, **kwargs)
|
114
|
+
|
115
|
+
def command(
|
116
|
+
self, name: str | None = None, *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
|
117
|
+
) -> Callable[[typing.Any], typing.Any]:
|
118
|
+
"""
|
119
|
+
Create a new command. If aliases are provided, the same command function
|
120
|
+
will be registered with multiple names.
|
121
|
+
"""
|
122
|
+
|
123
|
+
aliases = aliases or []
|
124
|
+
|
125
|
+
def wrapper(fn: Callable[[typing.Any], typing.Any]) -> Callable[[typing.Any], typing.Any]:
|
126
|
+
# click doesn't support async functions, so we wrap them in
|
127
|
+
# asyncio.run(). This has the advantage of keeping the function in
|
128
|
+
# the main thread, which means signal handling works for e.g. the
|
129
|
+
# server and workers. However, it means that async CLI commands can
|
130
|
+
# not directly call other async CLI commands (because asyncio.run()
|
131
|
+
# can not be called nested). In that (rare) circumstance, refactor
|
132
|
+
# the CLI command so its business logic can be invoked separately
|
133
|
+
# from its entrypoint.
|
134
|
+
if is_async_fn(fn):
|
135
|
+
_fn = fn
|
136
|
+
|
137
|
+
@functools.wraps(fn)
|
138
|
+
def fn(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
139
|
+
return asyncio.run(_fn(*args, **kwargs)) # type: ignore
|
140
|
+
|
141
|
+
fn.aio = _fn # type: ignore
|
142
|
+
|
143
|
+
fn = with_cli_exception_handling(fn)
|
144
|
+
|
145
|
+
# register fn with its original name
|
146
|
+
command_decorator = super(AsyncTyper, self).command(name=name, *args, **kwargs)
|
147
|
+
original_command = command_decorator(fn)
|
148
|
+
|
149
|
+
# register fn for each alias, e.g. @marvin_app.command(aliases=["r"])
|
150
|
+
for alias in aliases:
|
151
|
+
super(AsyncTyper, self).command(
|
152
|
+
name=alias,
|
153
|
+
*args,
|
154
|
+
**{k: v for k, v in kwargs.items() if k != "aliases"},
|
155
|
+
)(fn)
|
156
|
+
|
157
|
+
return typing.cast(Callable[[typing.Any], typing.Any], original_command)
|
158
|
+
|
159
|
+
return wrapper
|
160
|
+
|
161
|
+
def setup_console(self, soft_wrap: bool = True, prompt: bool = True) -> None:
|
162
|
+
self.console = Console(
|
163
|
+
highlight=False,
|
164
|
+
color_system="auto",
|
165
|
+
theme=Theme({"prompt.choices": "bold blue"}),
|
166
|
+
soft_wrap=not soft_wrap,
|
167
|
+
force_interactive=prompt,
|
168
|
+
)
|
169
|
+
|
170
|
+
|
171
|
+
base_app = AsyncTyper(add_completion=True, no_args_is_help=True)
|
172
|
+
register_main_callback(base_app)
|