remotivelabs-cli 0.0.42__py3-none-any.whl → 0.1.0a1__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.
Files changed (43) hide show
  1. cli/.DS_Store +0 -0
  2. cli/api/cloud/tokens.py +62 -0
  3. cli/broker/brokers.py +0 -1
  4. cli/broker/export.py +4 -4
  5. cli/broker/lib/broker.py +9 -13
  6. cli/broker/license_flows.py +1 -1
  7. cli/broker/scripting.py +2 -1
  8. cli/broker/signals.py +9 -10
  9. cli/cloud/auth/cmd.py +25 -6
  10. cli/cloud/auth/login.py +279 -24
  11. cli/cloud/auth_tokens.py +295 -12
  12. cli/cloud/brokers.py +3 -4
  13. cli/cloud/cloud_cli.py +2 -2
  14. cli/cloud/configs.py +1 -2
  15. cli/cloud/organisations.py +90 -2
  16. cli/cloud/projects.py +1 -2
  17. cli/cloud/recordings.py +9 -16
  18. cli/cloud/recordings_playback.py +6 -8
  19. cli/cloud/sample_recordings.py +2 -3
  20. cli/cloud/service_account_tokens.py +21 -5
  21. cli/cloud/service_accounts.py +32 -4
  22. cli/cloud/storage/cmd.py +1 -1
  23. cli/cloud/storage/copy.py +3 -4
  24. cli/connect/connect.py +1 -1
  25. cli/connect/protopie/protopie.py +12 -14
  26. cli/remotive.py +30 -6
  27. cli/settings/__init__.py +1 -2
  28. cli/settings/config_file.py +85 -0
  29. cli/settings/core.py +195 -46
  30. cli/settings/migrate_all_token_files.py +74 -0
  31. cli/settings/migrate_token_file.py +52 -0
  32. cli/settings/token_file.py +69 -4
  33. cli/tools/can/can.py +2 -2
  34. cli/typer/typer_utils.py +18 -1
  35. cli/utils/__init__.py +0 -0
  36. cli/{cloud → utils}/rest_helper.py +109 -38
  37. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/METADATA +6 -4
  38. remotivelabs_cli-0.1.0a1.dist-info/RECORD +59 -0
  39. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/WHEEL +1 -1
  40. cli/settings/cmd.py +0 -72
  41. remotivelabs_cli-0.0.42.dist-info/RECORD +0 -54
  42. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/LICENSE +0 -0
  43. {remotivelabs_cli-0.0.42.dist-info → remotivelabs_cli-0.1.0a1.dist-info}/entry_points.txt +0 -0
cli/settings/core.py CHANGED
@@ -1,15 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
- import datetime
3
+ import os
4
+ import re
4
5
  import shutil
6
+ import stat
5
7
  import sys
6
- from json import JSONDecodeError
8
+ from dataclasses import dataclass
7
9
  from pathlib import Path
8
- from typing import Tuple
10
+ from typing import Optional, Tuple, Union
9
11
 
10
12
  from rich.console import Console
11
13
 
14
+ from cli.errors import ErrorPrinter
15
+ from cli.settings import config_file
12
16
  from cli.settings import token_file as tf
17
+ from cli.settings.config_file import ConfigFile
13
18
  from cli.settings.token_file import TokenFile
14
19
 
15
20
  err_console = Console(stderr=True)
@@ -19,6 +24,7 @@ CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
19
24
  INCORRECT_CONFIG_DIR_PATH = Path.home() / ".config" / ".remotive"
20
25
  DEPRECATED_CONFIG_DIR_PATH = Path.home() / ".remotive"
21
26
 
27
+ CLI_CONFIG_FILE_NAME = "config.json"
22
28
  ACTIVE_TOKEN_FILE_NAME = "cloud.secret.token"
23
29
  PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
24
30
  SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
@@ -31,10 +37,19 @@ class InvalidSettingsFilePathError(Exception):
31
37
  """Raised when trying to access an invalid settings file or file path"""
32
38
 
33
39
 
40
+ class NotFoundError(Exception):
41
+ """Raised when a token cannot be found in settings"""
42
+
43
+
34
44
  class TokenNotFoundError(Exception):
35
45
  """Raised when a token cannot be found in settings"""
36
46
 
37
47
 
48
+ @dataclass()
49
+ class CliConfigFile:
50
+ default_organisation: Union[str, None]
51
+
52
+
38
53
  class Settings:
39
54
  """
40
55
  Settings for the remotive CLI
@@ -45,8 +60,8 @@ class Settings:
45
60
  def __init__(self, config_dir: Path, deprecated_config_dirs: list[Path] | None = None) -> None:
46
61
  self.config_dir = config_dir
47
62
  self._active_secret_token_path = self.config_dir / ACTIVE_TOKEN_FILE_NAME
63
+ self._cli_config = self.config_dir / CLI_CONFIG_FILE_NAME
48
64
 
49
- # no migration of deprecated config dirs if the new config dir already exists
50
65
  if self.config_dir.exists():
51
66
  return
52
67
 
@@ -56,6 +71,65 @@ class Settings:
56
71
  for deprecated_config_dir in deprecated_config_dirs:
57
72
  self._migrate_legacy_config_dir(deprecated_config_dir)
58
73
 
74
+ # def _write_properties(self, filepath: Path, props: CliConfigFile) -> None:
75
+ # with open(filepath, "w", encoding="utf-8") as file:
76
+ # # keys = sorted(props.keys()) if sort_keys else props.keys()
77
+ # # for key in keys:
78
+ # file.write(f"default_organisation={props.default_organisation}\n")
79
+
80
+ def _read_properties(self, filepath: Path) -> CliConfigFile:
81
+ props = {}
82
+ with open(filepath, "r", encoding="utf-8") as file:
83
+ for line_num, line in enumerate(file, start=1):
84
+ line_stripped = line.strip()
85
+ if not line_stripped or line_stripped.startswith("#"):
86
+ continue
87
+ if "=" not in line_stripped:
88
+ raise ValueError(f"Invalid line format at line {line_num}: {line}")
89
+ key, value = line_stripped.split("=", 1)
90
+ key, value = key.strip(), value.strip()
91
+ if key in props:
92
+ raise ValueError(f"Duplicate key '{key}' found at line {line_num}")
93
+ props[key] = value
94
+ if "default_organisation" in props:
95
+ return CliConfigFile(default_organisation=props["default_organisation"])
96
+ return CliConfigFile(default_organisation=None)
97
+
98
+ def set_default_organisation(self, organisation: str) -> None:
99
+ cli_config = self.get_cli_config()
100
+
101
+ try:
102
+ token = settings.get_active_token_file()
103
+ cli_config.set_account_field(token.account.email, organisation)
104
+ self._write_config_file(cli_config)
105
+ except TokenNotFoundError:
106
+ ErrorPrinter.print_hint("You must have an account activated in order to set default organisation")
107
+ sys.exit(1)
108
+
109
+ # def set_default_config_as_env(self) -> None:
110
+ # config = self.get_cli_config()
111
+ # if config.default_organisation is not None:
112
+ # if "REMOTIVE_CLOUD_ORGANISATION" not in os.environ:
113
+ # os.environ["REMOTIVE_CLOUD_ORGANISATION"] = config.default_organisation
114
+
115
+ # def get_active_cli_account(self) -> Optional[Account]:
116
+ # try:
117
+ # token = self.get_active_token_file()
118
+ # config = self.get_cli_config()
119
+ # if token.account.email in config.accounts:
120
+ # return config.accounts[token.account.email]
121
+ # return None
122
+ # except TokenNotFoundError:
123
+ # ErrorPrinter.print_hint("Cannot get active config without activating account credentials first")
124
+ # sys.exit(1)
125
+
126
+ def get_cli_config(self) -> ConfigFile:
127
+ try:
128
+ return self._read_config_file()
129
+ except TokenNotFoundError:
130
+ return ConfigFile()
131
+ # self._write_properties(CONFIG_DIR_PATH / CLI_CONFIG_FILE_NAME, [])
132
+
59
133
  def get_active_token(self) -> str:
60
134
  """
61
135
  Get the current active token secret
@@ -67,25 +141,48 @@ class Settings:
67
141
  """
68
142
  Get the current active token file
69
143
  """
70
- if not self._active_secret_token_path.exists():
71
- raise TokenNotFoundError("no active token file found")
72
144
 
73
- return self._read_token_file(self._active_secret_token_path)
145
+ active_account = self.get_cli_config().get_active()
146
+ if active_account is not None:
147
+ token_name = active_account.credentials_name
148
+ return self.get_token_file(token_name)
149
+ raise TokenNotFoundError
150
+ # if not self._active_secret_token_path.exists():
151
+ # raise TokenNotFoundError("no active token file found")
152
+ # return self._read_token_file(self._active_secret_token_path)
74
153
 
75
- def activate_token(self, name: str) -> None:
154
+ def activate_token(self, token: TokenFile) -> None:
76
155
  """
77
156
  Activate a token by name or path
78
157
 
79
158
  The token secret will be set as the current active secret.
80
159
  """
81
- token_file = self.get_token_file(name)
82
- self._write_token_file(self._active_secret_token_path, token_file)
160
+ # token_file = self.get_token_file(name)
161
+ cli_config = self.get_cli_config()
162
+ cli_config.activate(token.account.email)
163
+ # if token_file.account.email not in cli_config.accounts:
164
+ # cli_config.set_account_field(token_file.account.email)
165
+ self._write_config_file(cli_config)
166
+ # self._write_token_file(self._active_secret_token_path, token_file)
83
167
 
84
168
  def clear_active_token(self) -> None:
85
169
  """
86
170
  Clear the current active token
87
171
  """
88
- self._active_secret_token_path.unlink(missing_ok=True)
172
+ config = self.get_cli_config()
173
+ config.active = None
174
+ self._write_config_file(config)
175
+
176
+ # self._active_secret_token_path.unlink(missing_ok=True)
177
+
178
+ def get_token_file_by_email(self, email: str) -> Optional[TokenFile]:
179
+ tokens = [t for t in self.list_personal_tokens() if t.account is not None and t.account.email == email]
180
+ if len(tokens) > 0:
181
+ return tokens[0]
182
+ tokens = [t for t in self.list_service_account_tokens() if t.account is not None and t.account.email == email]
183
+ if len(tokens) > 0:
184
+ return tokens[0]
185
+ return None
89
186
 
90
187
  def get_token_file(self, name: str) -> TokenFile:
91
188
  """
@@ -93,6 +190,8 @@ class Settings:
93
190
  """
94
191
  if Path(name).exists():
95
192
  return self._read_token_file(Path(name))
193
+ if Path(CONFIG_DIR_PATH / name).exists():
194
+ return self._read_token_file(Path(CONFIG_DIR_PATH / name))
96
195
 
97
196
  return self._get_token_by_name(name)[0]
98
197
 
@@ -107,18 +206,19 @@ class Settings:
107
206
  raise InvalidSettingsFilePathError(f"cannot remove a token file not located in settings dir {self.config_dir}")
108
207
  return Path(name).unlink()
109
208
 
110
- # TODO: what about the active token? # pylint: disable=fixme
111
-
209
+ # TODO: what about the active token?
112
210
  path = self._get_token_by_name(name)[1]
211
+ # print("Deleting", path)
212
+ print(path)
113
213
  return path.unlink()
114
214
 
115
- def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
116
- """
117
- Activates a short lived token
118
- """
119
- token_file = tf.loads(token)
120
- self._write_token_file(self._active_secret_token_path, token_file)
121
- return token_file
215
+ # def add_and_activate_short_lived_cli_token(self, token: str) -> TokenFile:
216
+ # """
217
+ # Activates a short lived token
218
+ # """
219
+ # token_file = tf.loads(token)
220
+ # self._write_token_file(self._active_secret_token_path, token_file)
221
+ # return token_file
122
222
 
123
223
  def add_personal_token(
124
224
  self,
@@ -131,15 +231,24 @@ class Settings:
131
231
  """
132
232
  token_file = tf.loads(token)
133
233
 
134
- file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}.json"
234
+ def email_to_safe_filename(email: str) -> str:
235
+ # Replace any invalid character with an underscore
236
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
237
+
238
+ # From now, user will never be None when adding a token so in this case token_file.user is never None
239
+ email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
240
+ file = f"{PERSONAL_TOKEN_FILE_PREFIX}{token_file.name}-{email}.json"
135
241
  path = self.config_dir / file
136
242
  if path.exists() and not overwrite_if_exists:
137
243
  raise FileExistsError(f"Token file already exists: {path}")
138
244
 
139
245
  self._write_token_file(path, token_file)
246
+ cli_config = self.get_cli_config()
247
+ cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
248
+ self._write_config_file(cli_config)
140
249
 
141
250
  if activate:
142
- self.activate_token(token_file.name)
251
+ self.activate_token(token_file)
143
252
 
144
253
  return token_file
145
254
 
@@ -155,20 +264,38 @@ class Settings:
155
264
  """
156
265
  return [f[1] for f in self._list_personal_tokens()]
157
266
 
158
- def add_service_account_token(self, service_account: str, token: str) -> TokenFile:
159
- """
160
- Add a service account token to the config directory
161
- """
267
+ def add_service_account_token(self, token: str) -> TokenFile:
162
268
  token_file = tf.loads(token)
163
269
 
164
- file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
270
+ def email_to_safe_filename(email: str) -> str:
271
+ # Replace any invalid character with an underscore
272
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
273
+
274
+ # From now, user will never be None when adding a token so in this case token_file.user is never None
275
+
276
+ email = email_to_safe_filename(token_file.account.email) if token_file.account is not None else "unknown"
277
+ file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{email}-{token_file.name}.json"
165
278
  path = self.config_dir / file
166
- if path.exists():
167
- raise FileExistsError(f"Token file already exists: {path}")
168
279
 
169
280
  self._write_token_file(path, token_file)
281
+ cli_config = self.get_cli_config()
282
+ cli_config.init_account(email=token_file.account.email, token_name=token_file.name)
283
+ self._write_config_file(cli_config)
284
+
285
+ # if activate:
286
+ # self.activate_token(token_file.account.email)
287
+
170
288
  return token_file
171
289
 
290
+ # token_file = tf.loads(token)
291
+ # file = f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{service_account}-{token_file.name}.json"
292
+ # path = self.config_dir / file
293
+ # if path.exists():
294
+ # raise FileExistsError(f"Token file already exists: {path}")
295
+
296
+ # self._write_token_file(path, token_file)
297
+ # return token_file
298
+
172
299
  def list_service_account_tokens(self) -> list[TokenFile]:
173
300
  """
174
301
  List all service account tokens
@@ -195,13 +322,15 @@ class Settings:
195
322
  return matches[0]
196
323
 
197
324
  def _list_token_files(self, prefix: str = "") -> list[TokenFileMetadata]:
198
- # list all tokens with the correct prefix in the config dir, but omit the special active token file
199
- def is_path_prefixed_and_not_active_secret(path: Path) -> bool:
325
+ """list all tokens with the correct prefix in the config dir, but omit files that are not token files"""
326
+
327
+ def is_valid_token_file(path: Path) -> bool:
200
328
  has_correct_prefix = path.is_file() and path.name.startswith(prefix)
201
329
  is_active_secret = path == self._active_secret_token_path
202
- return has_correct_prefix and not is_active_secret
330
+ is_cli_config = path == self._cli_config
331
+ return has_correct_prefix and not is_active_secret and not is_cli_config
203
332
 
204
- paths = [path for path in self.config_dir.iterdir() if is_path_prefixed_and_not_active_secret(path)]
333
+ paths = [path for path in self.config_dir.iterdir() if is_valid_token_file(path)]
205
334
 
206
335
  return [(self._read_token_file(token_file), token_file) for token_file in paths]
207
336
 
@@ -209,6 +338,10 @@ class Settings:
209
338
  data = self._read_file(path)
210
339
  return tf.loads(data)
211
340
 
341
+ def _read_config_file(self) -> ConfigFile:
342
+ data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
343
+ return config_file.loads(data)
344
+
212
345
  def _read_file(self, path: Path) -> str:
213
346
  if not path.exists():
214
347
  raise TokenNotFoundError(f"File could not be found: {path}")
@@ -216,7 +349,15 @@ class Settings:
216
349
 
217
350
  def _write_token_file(self, path: Path, token: TokenFile) -> Path:
218
351
  data = tf.dumps(token)
219
- return self._write_file(path, data)
352
+ path = self._write_file(path, data)
353
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
354
+ return path
355
+
356
+ def _write_config_file(self, config: ConfigFile) -> Path:
357
+ data = config_file.dumps(config)
358
+ path = self._write_file(self._cli_config, data)
359
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
360
+ return path
220
361
 
221
362
  def _write_file(self, path: Path, data: str) -> Path:
222
363
  if self.config_dir not in path.parents:
@@ -233,24 +374,32 @@ class Settings:
233
374
  shutil.copytree(str(path), str(self.config_dir), dirs_exist_ok=True)
234
375
  secret = path / ACTIVE_TOKEN_FILE_NAME
235
376
  if secret.exists():
236
- value = secret.read_text(encoding="utf-8").strip()
377
+ sys.stderr.write(f"Removing old activated token {secret}")
378
+ secret.unlink(missing_ok=True)
379
+ # value = secret.read_text(encoding="utf-8").strip()
237
380
  # The existing token file might either be a token file, or simply a string. We handle both cases...
238
- try:
239
- token = tf.loads(value)
240
- except JSONDecodeError:
241
- token = tf.TokenFile(
242
- name="MigratedActiveToken",
243
- token=value,
244
- created=str(datetime.datetime.now().isoformat()),
245
- expires="unknown",
246
- )
247
- self.add_and_activate_short_lived_cli_token(tf.dumps(token))
381
+ # try:
382
+ # token = tf.loads(value)
383
+ # except JSONDecodeError:
384
+ # token = tf.TokenFile(
385
+ # version="1.0",
386
+ # type="service-account" if value.startswith("sa") else "authorized_user",
387
+ # name="MigratedActiveToken",
388
+ # token=value,
389
+ # created=str(datetime.datetime.now().isoformat()),
390
+ # expires="unknown",
391
+ # account=TokenFileAccount(email="unknown@remotivecloud.com"),
392
+ # )
393
+ # self.add_and_activate_short_lived_cli_token(tf.dumps(token))
248
394
  shutil.rmtree(str(path))
249
395
 
250
396
 
251
397
  def create_settings() -> Settings:
252
398
  """Create remotive CLI config directory and return its settings instance"""
253
- return Settings(CONFIG_DIR_PATH, deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH])
399
+ return Settings(
400
+ CONFIG_DIR_PATH,
401
+ deprecated_config_dirs=[DEPRECATED_CONFIG_DIR_PATH, INCORRECT_CONFIG_DIR_PATH],
402
+ )
254
403
 
255
404
 
256
405
  settings = create_settings()
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from cli.settings import settings
4
+ from cli.settings.core import ACTIVE_TOKEN_FILE_NAME
5
+ from cli.settings.migrate_token_file import InvalidTokenError, UnsupportedTokenVersionError, migrate_legacy_token
6
+ from cli.settings.token_file import TokenFile, dumps
7
+
8
+
9
+ def _migrate_legacy_tokens(tokens: list[TokenFile]) -> tuple[list[TokenFile], set[str]]:
10
+ """
11
+ Determine which tokens can be updated and which should be removed.
12
+
13
+ Returns:
14
+ tuple of (updated_tokens, invalid_tokens)
15
+ """
16
+ updated_tokens: list[TokenFile] = []
17
+ invalid_tokens: set[str] = set()
18
+
19
+ for token in tokens:
20
+ try:
21
+ migrated_token = migrate_legacy_token(token)
22
+ if migrated_token.version != token.version:
23
+ updated_tokens.append(migrated_token)
24
+ except (InvalidTokenError, UnsupportedTokenVersionError):
25
+ # Token not valid or unsupported version, mark for removal
26
+ invalid_tokens.add(token.name)
27
+
28
+ return updated_tokens, invalid_tokens
29
+
30
+
31
+ def _write_updated_tokens(updated_tokens: list[TokenFile]) -> None:
32
+ for updated_token in updated_tokens:
33
+ settings.remove_token_file(name=updated_token.name)
34
+ if updated_token.type == "authorized_user":
35
+ settings.add_personal_token(dumps(updated_token), overwrite_if_exists=True)
36
+ elif updated_token.type == "service_account":
37
+ settings.add_service_account_token(dumps(updated_token))
38
+ else:
39
+ raise ValueError(f"Unsupported token type: {updated_token.type}")
40
+
41
+
42
+ def _remove_invalid_tokens(invalid_tokens: set[str]) -> None:
43
+ for token_name in invalid_tokens:
44
+ settings.remove_token_file(name=token_name)
45
+
46
+
47
+ def _remove_old_secret_file() -> bool:
48
+ old_activated_secret_file = settings.config_dir / ACTIVE_TOKEN_FILE_NAME
49
+ old_secret_exists = old_activated_secret_file.exists()
50
+ if old_secret_exists:
51
+ old_activated_secret_file.unlink(missing_ok=True)
52
+ return old_secret_exists
53
+
54
+
55
+ def migrate_any_legacy_tokens(tokens: list[TokenFile]) -> bool:
56
+ """
57
+ Migrate any legacy tokens to the latest TokenFile format.
58
+
59
+ Returns True if any tokens were migrated, False otherwise.
60
+ """
61
+ # Get tokens to update/remove
62
+ updated_tokens, invalid_tokens = _migrate_legacy_tokens(tokens)
63
+
64
+ # Perform file operations
65
+ _write_updated_tokens(updated_tokens)
66
+ _remove_invalid_tokens(invalid_tokens)
67
+
68
+ # Remove old secret file if exists
69
+ old_secret_removed = _remove_old_secret_file()
70
+ if old_secret_removed:
71
+ return True # We migrated at least one token
72
+
73
+ # only return True if we migrated at least one token
74
+ return len(updated_tokens) + len(invalid_tokens) > 0
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from cli.settings.token_file import TokenFile, TokenFileAccount
4
+ from cli.utils.rest_helper import RestHelper
5
+
6
+
7
+ class InvalidTokenError(Exception):
8
+ """Raised when a token is invalid."""
9
+
10
+
11
+ class UnsupportedTokenVersionError(Exception):
12
+ """Raised when a token version is not supported."""
13
+
14
+
15
+ def migrate_legacy_token(token: TokenFile) -> TokenFile:
16
+ """
17
+ Migrate a token from a legacy format to the latest format.
18
+
19
+ Args:
20
+ token: The token to migrate.
21
+
22
+ Returns:
23
+ TokenFile: The migrated token.
24
+
25
+ Raises:
26
+ InvalidTokenError: If the token is invalid.
27
+ UnsupportedTokenVersionError: If the token version is not supported.
28
+ """
29
+ # use a naive approach to compare versions for now
30
+ version = float(token.version)
31
+
32
+ # already migrated
33
+ if version >= 1.1:
34
+ return token
35
+
36
+ if version == 1.0:
37
+ res = RestHelper.handle_get("/api/whoami", return_response=True, allow_status_codes=[401, 400, 403], access_token=token.token)
38
+ if res.status_code != 200:
39
+ raise InvalidTokenError(f"Token {token.name} is invalid")
40
+
41
+ email = res.json()["email"]
42
+ return TokenFile(
43
+ version="1.1",
44
+ type=token.type,
45
+ name=token.name,
46
+ token=token.token,
47
+ created=token.created,
48
+ expires=token.expires,
49
+ account=TokenFileAccount(email=email),
50
+ )
51
+
52
+ raise UnsupportedTokenVersionError(f"Unsupported token version: {token.version}")
@@ -3,20 +3,85 @@ from __future__ import annotations
3
3
  import dataclasses
4
4
  import json
5
5
  from dataclasses import dataclass
6
+ from datetime import date, datetime
7
+ from typing import Any, Literal
8
+
9
+ DEFAULT_EMAIL = "unknown@remotivecloud.com"
10
+
11
+ TokenType = Literal["authorized_user", "service_account"]
12
+
13
+
14
+ def _parse_date(date_str: str) -> date:
15
+ normalized = date_str.replace("Z", "+00:00")
16
+ return datetime.fromisoformat(normalized).date()
17
+
18
+
19
+ def _parse_token_type(token: str) -> TokenType:
20
+ if token.startswith("pa"):
21
+ return "authorized_user"
22
+ if token.startswith("sa"):
23
+ return "service_account"
24
+ raise ValueError(f"Unknown token type for token: {token}")
25
+
26
+
27
+ def _from_dict(d: dict[str, Any]) -> TokenFile:
28
+ if "version" not in d:
29
+ token_type = _parse_token_type(d["token"])
30
+ return TokenFile(
31
+ version="1.0",
32
+ type=token_type,
33
+ name=d["name"],
34
+ token=d["token"],
35
+ created=_parse_date(d["created"]),
36
+ expires=_parse_date(d["expires"]),
37
+ account=TokenFileAccount(email=DEFAULT_EMAIL),
38
+ )
39
+
40
+ account_email = d.get("account", {}).get("email", DEFAULT_EMAIL)
41
+ return TokenFile(
42
+ version=d["version"],
43
+ type=d["type"],
44
+ name=d["name"],
45
+ token=d["token"],
46
+ created=_parse_date(d["created"]),
47
+ expires=_parse_date(d["expires"]),
48
+ account=TokenFileAccount(email=account_email),
49
+ )
6
50
 
7
51
 
8
52
  def loads(data: str) -> TokenFile:
9
- d = json.loads(data)
10
- return TokenFile(name=d["name"], token=d["token"], created=d["created"], expires=d["expires"])
53
+ return _from_dict(json.loads(data))
11
54
 
12
55
 
13
56
  def dumps(token: TokenFile) -> str:
14
57
  return json.dumps(dataclasses.asdict(token), default=str)
15
58
 
16
59
 
60
+ @dataclass
61
+ class TokenFileAccount:
62
+ email: str
63
+
64
+
17
65
  @dataclass
18
66
  class TokenFile:
67
+ version: str
68
+ type: TokenType
19
69
  name: str
20
70
  token: str
21
- created: str
22
- expires: str
71
+ created: date
72
+ expires: date
73
+ account: TokenFileAccount
74
+
75
+ def is_expired(self) -> bool:
76
+ return datetime.today().date() > self.expires
77
+
78
+ def expires_in_days(self) -> int:
79
+ return (self.expires - datetime.today().date()).days
80
+
81
+ @staticmethod
82
+ def from_json_str(data: str) -> TokenFile:
83
+ return loads(data)
84
+
85
+ @staticmethod
86
+ def from_dict(data: dict[str, Any]) -> TokenFile:
87
+ return _from_dict(data)
cli/tools/can/can.py CHANGED
@@ -48,7 +48,7 @@ def convert(
48
48
  with can.Logger(out_file) as writer:
49
49
  for msg in reader:
50
50
  writer.on_message_received(msg)
51
- except Exception as e: # pylint: disable=W0718
51
+ except Exception as e:
52
52
  err_console.print(f":boom: [bold red]Failed to convert file[/bold red]: {e}")
53
53
 
54
54
 
@@ -77,5 +77,5 @@ def validate(
77
77
  if print_to_terminal:
78
78
  writer.on_message_received(msg)
79
79
  console.print(f"Successfully verified {in_file}")
80
- except Exception as e: # pylint: disable=W0718
80
+ except Exception as e:
81
81
  err_console.print(f":boom: [bold red]Failed to convert file[/bold red]: {e}")
cli/typer/typer_utils.py CHANGED
@@ -1,8 +1,25 @@
1
1
  from typing import Any
2
2
 
3
3
  import typer
4
+ from click import Context
5
+ from rich.console import Console
6
+ from typer.core import TyperGroup
7
+
8
+
9
+ class OrderCommands(TyperGroup):
10
+ def list_commands(self, _ctx: Context): # type: ignore
11
+ return list(self.commands)
12
+
13
+
14
+ console = Console()
4
15
 
5
16
 
6
17
  def create_typer(**kwargs: Any) -> typer.Typer:
7
18
  """Create a Typer instance with default settings."""
8
- return typer.Typer(no_args_is_help=True, **kwargs)
19
+ # return typer.Typer(no_args_is_help=True, **kwargs)
20
+ return typer.Typer(cls=OrderCommands, no_args_is_help=True, **kwargs)
21
+
22
+
23
+ def print_padded(label: str, right_text: str, length: int = 30) -> None:
24
+ padded_label = label.ljust(length) # pad to 30 characters
25
+ console.print(f"{padded_label} {right_text}")
cli/utils/__init__.py ADDED
File without changes