remotivelabs-cli 0.2.1__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of remotivelabs-cli might be problematic. Click here for more details.

Files changed (68) hide show
  1. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/PKG-INFO +3 -2
  2. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth_tokens.py +4 -13
  3. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/organisations.py +5 -6
  4. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/remotive.py +11 -8
  5. remotivelabs_cli-0.2.3/cli/settings/config_file.py +111 -0
  6. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/core.py +64 -43
  7. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_config_file.py +2 -4
  8. remotivelabs_cli-0.2.3/cli/settings/state_file.py +59 -0
  9. remotivelabs_cli-0.2.3/cli/settings/token_file.py +128 -0
  10. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/topology/cmd.py +2 -1
  11. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/typer/typer_utils.py +1 -1
  12. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/utils/rest_helper.py +8 -11
  13. remotivelabs_cli-0.2.3/cli/utils/time.py +11 -0
  14. remotivelabs_cli-0.2.3/cli/utils/versions.py +132 -0
  15. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/pyproject.toml +3 -2
  16. remotivelabs_cli-0.2.1/cli/settings/config_file.py +0 -93
  17. remotivelabs_cli-0.2.1/cli/settings/token_file.py +0 -101
  18. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/LICENSE +0 -0
  19. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/README.md +0 -0
  20. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/.DS_Store +0 -0
  21. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/__init__.py +0 -0
  22. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/api/cloud/tokens.py +0 -0
  23. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/brokers.py +0 -0
  24. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/export.py +0 -0
  25. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/files.py +0 -0
  26. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/lib/__about__.py +0 -0
  27. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/lib/broker.py +0 -0
  28. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/license_flows.py +0 -0
  29. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/licenses.py +0 -0
  30. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/playback.py +0 -0
  31. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/record.py +0 -0
  32. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/scripting.py +0 -0
  33. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/signals.py +0 -0
  34. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/__init__.py +0 -0
  35. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/__init__.py +0 -0
  36. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/cmd.py +0 -0
  37. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/login.py +0 -0
  38. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/brokers.py +0 -0
  39. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/cloud_cli.py +0 -0
  40. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/configs.py +0 -0
  41. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/projects.py +0 -0
  42. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/recordings.py +0 -0
  43. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/recordings_playback.py +0 -0
  44. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/resumable_upload.py +0 -0
  45. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/sample_recordings.py +0 -0
  46. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/service_account_tokens.py +0 -0
  47. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/service_accounts.py +0 -0
  48. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/__init__.py +0 -0
  49. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/cmd.py +0 -0
  50. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/copy.py +0 -0
  51. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/uri_or_path.py +0 -0
  52. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/uri.py +0 -0
  53. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/__init__.py +0 -0
  54. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/connect.py +0 -0
  55. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/protopie/protopie.py +0 -0
  56. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/errors.py +0 -0
  57. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/__init__.py +0 -0
  58. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/__init__.py +0 -0
  59. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_all_token_files.py +0 -0
  60. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_legacy_dirs.py +0 -0
  61. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_token_file.py +0 -0
  62. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migration_tools.py +0 -0
  63. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/__init__.py +0 -0
  64. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/can/__init__.py +0 -0
  65. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/can/can.py +0 -0
  66. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/tools.py +0 -0
  67. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/typer/__init__.py +0 -0
  68. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: remotivelabs-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
@@ -12,10 +12,11 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Dist: click (<8.2.0)
15
- Requires-Dist: dacite (>=1.9.2,<2.0.0)
15
+ Requires-Dist: email-validator (>=2.2.0,<3.0.0)
16
16
  Requires-Dist: grpc-stubs (>=1.53.0.5)
17
17
  Requires-Dist: mypy-protobuf (>=3.0.0)
18
18
  Requires-Dist: plotext (>=5.2,<6.0)
19
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
19
20
  Requires-Dist: pyjwt (>=2.6,<3.0)
20
21
  Requires-Dist: python-can (>=4.3.1)
21
22
  Requires-Dist: python-socketio (>=4.6.1)
@@ -10,7 +10,6 @@ from cli.api.cloud import tokens
10
10
  from cli.cloud.organisations import do_select_default_org
11
11
  from cli.errors import ErrorPrinter
12
12
  from cli.settings import TokenNotFoundError, settings
13
- from cli.settings.config_file import Account
14
13
  from cli.settings.token_file import TokenFile
15
14
  from cli.typer import typer_utils
16
15
  from cli.utils.rest_helper import RestHelper as Rest
@@ -52,20 +51,11 @@ def _prompt_choice( # noqa: C901, PLR0912
52
51
 
53
52
  included_tokens.sort(key=lambda token: token.created, reverse=True)
54
53
 
55
- def get_active_account_or_none() -> Optional[Account]:
56
- try:
57
- return settings.get_cli_config().get_active()
58
- except TokenNotFoundError:
59
- return None
60
-
61
54
  def get_active_token_or_none() -> Optional[TokenFile]:
62
55
  try:
63
- active_account = get_active_account_or_none()
64
- if active_account is not None:
65
- return settings.get_token_file(active_account.credentials_file)
56
+ return settings.get_active_token_file()
66
57
  except TokenNotFoundError:
67
- pass
68
- return None
58
+ return None
69
59
 
70
60
  active_token = get_active_token_or_none()
71
61
  active_token_index = None
@@ -163,7 +153,8 @@ def activate(
163
153
 
164
154
 
165
155
  def prompt_to_set_org() -> None:
166
- if settings.get_cli_config().get_active_default_organisation() is None:
156
+ active_account = settings.get_cli_config().get_active_account()
157
+ if active_account and not active_account.default_organization:
167
158
  set_default_organisation = typer.confirm(
168
159
  "You have not set a default organization\nWould you like to choose one now?",
169
160
  abort=False,
@@ -74,18 +74,17 @@ def do_select_default_org(organisation_uid: Optional[str] = None, get: bool = Fa
74
74
  Note that service-accounts does Not have permission to list organizations and will get a 403 Forbidden response so you must
75
75
  select the organization uid as argument
76
76
  """
77
+ active_account = settings.get_cli_config().get_active_account()
77
78
  if get:
78
- default_organisation = settings.get_cli_config().get_active_default_organisation()
79
- if default_organisation:
80
- console.print(default_organisation)
79
+ if active_account and active_account.default_organization:
80
+ console.print(active_account.default_organization)
81
81
  else:
82
82
  console.print("No default organization set")
83
83
  elif organisation_uid is not None:
84
84
  settings.set_default_organisation(organisation_uid)
85
85
  else:
86
- account = settings.get_cli_config().get_active()
87
- if account:
88
- token = settings.get_token_file(account.credentials_file)
86
+ if active_account:
87
+ token = settings.get_token_file(active_account.credentials_file)
89
88
  if token.type != "authorized_user":
90
89
  ErrorPrinter.print_hint(
91
90
  "You must supply the organization name as argument when using a service-account since the "
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from importlib.metadata import version
5
4
 
6
5
  import typer
7
6
  from rich import print as rich_print
@@ -20,6 +19,7 @@ from cli.settings.migration.migrate_legacy_dirs import migrate_legacy_settings_d
20
19
  from cli.tools.tools import app as tools_app
21
20
  from cli.topology.cmd import app as topology_app
22
21
  from cli.typer import typer_utils
22
+ from cli.utils import versions
23
23
 
24
24
  err_console = Console(stderr=True)
25
25
 
@@ -44,9 +44,7 @@ For documentation - https://docs.remotivelabs.com
44
44
 
45
45
  def version_callback(value: bool) -> None:
46
46
  if value:
47
- my_version = version("remotivelabs-cli")
48
- typer.echo(my_version)
49
- raise typer.Exit()
47
+ typer.echo(f"remotivelabs-cli {versions.cli_version()} ({versions.platform_info()})")
50
48
 
51
49
 
52
50
  def test_callback(value: int) -> None:
@@ -55,6 +53,10 @@ def test_callback(value: int) -> None:
55
53
  raise typer.Exit()
56
54
 
57
55
 
56
+ def check_for_newer_version(settings: Settings) -> None:
57
+ versions.check_for_update(settings)
58
+
59
+
58
60
  def run_migrations(settings: Settings) -> None:
59
61
  """
60
62
  Run all migration scripts.
@@ -80,9 +82,9 @@ def set_default_org_as_env(settings: Settings) -> None:
80
82
  This has to be done early before it is read
81
83
  """
82
84
  if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
83
- org = settings.get_cli_config().get_active_default_organisation()
84
- if org is not None:
85
- os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
85
+ active_account = settings.get_cli_config().get_active_account()
86
+ if active_account and active_account.default_organization:
87
+ os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = active_account.default_organization
86
88
 
87
89
 
88
90
  @app.callback()
@@ -91,11 +93,12 @@ def main(
91
93
  None,
92
94
  "--version",
93
95
  callback=version_callback,
94
- is_eager=False,
96
+ is_eager=True,
95
97
  help="Print current version",
96
98
  ),
97
99
  ) -> None:
98
100
  run_migrations(settings)
101
+ check_for_newer_version(settings)
99
102
  set_default_org_as_env(settings)
100
103
  # Do other global stuff, handle other global options here
101
104
 
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ from pydantic import BaseModel, Field, model_validator
6
+
7
+ from cli.settings.token_file import TokenFile
8
+
9
+
10
+ class Account(BaseModel):
11
+ """
12
+ Account represents an account in the configuration file.
13
+ """
14
+
15
+ credentials_file: str
16
+ default_organization: Optional[str] = None
17
+
18
+
19
+ class ConfigFile(BaseModel):
20
+ """
21
+ ConfigFile represents the configuration file for the CLI.
22
+
23
+ TODO: Should all setters return a new instance of the ConfigFile?
24
+ """
25
+
26
+ version: str = "1.0"
27
+ active: Optional[str] = None
28
+ accounts: dict[str, Account] = Field(default_factory=dict)
29
+
30
+ @model_validator(mode="before")
31
+ @classmethod
32
+ def _validate_json_data(cls, json_data: Any) -> Any:
33
+ """Try to migrate old formats and missing fields as best we can."""
34
+ if not isinstance(json_data, dict):
35
+ return json_data
36
+
37
+ # If the active account is not in accounts, remove it
38
+ if "active" in json_data and json_data["active"] not in json_data["accounts"]:
39
+ del json_data["active"]
40
+
41
+ return json_data
42
+
43
+ def get_active_account(self) -> Optional[Account]:
44
+ if not self.active:
45
+ return None
46
+ account = self.get_account(self.active)
47
+ if not account:
48
+ raise KeyError(f"Activated account {self.active} is not a valid account")
49
+ return account
50
+
51
+ def activate_account(self, email: str) -> None:
52
+ account = self.get_account(email)
53
+ if not account:
54
+ raise KeyError(f"Account {email} does not exists")
55
+ self.active = email
56
+
57
+ def _update_account(self, email: str, **updates: Any) -> None:
58
+ """TODO: Consider using model_copy and always return a new instance of ConfigFile"""
59
+ existing_account = self.get_account(email)
60
+ if existing_account:
61
+ updated_account = existing_account.model_copy(update=updates)
62
+ else:
63
+ updated_account = Account(**updates)
64
+
65
+ new_accounts = {**self.accounts, email: updated_account}
66
+ self.accounts = new_accounts
67
+
68
+ def init_account(self, email: str, token_file: TokenFile) -> None:
69
+ """
70
+ Create a new account with the given email and token file.
71
+ """
72
+ self._update_account(email, credentials_file=token_file.get_token_file_name())
73
+
74
+ def set_default_organization_for_account(self, email: str, default_organization: Optional[str] = None) -> None:
75
+ if not self.get_account(email):
76
+ raise KeyError(f"Account with email {email} has not been initialized with token")
77
+ self._update_account(email, default_organization=default_organization)
78
+
79
+ def get_account(self, email: str) -> Optional[Account]:
80
+ return self.accounts.get(email)
81
+
82
+ def remove_account(self, email: str) -> None:
83
+ self.accounts.pop(email, None)
84
+
85
+ @classmethod
86
+ def from_json_str(cls, data: str) -> ConfigFile:
87
+ return cls.model_validate_json(data)
88
+
89
+ @classmethod
90
+ def from_dict(cls, data: dict[str, Any]) -> ConfigFile:
91
+ return cls.model_validate(data)
92
+
93
+ def to_json_str(self) -> str:
94
+ return self.model_dump_json()
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ return self.model_dump()
98
+
99
+
100
+ def loads(data: str) -> ConfigFile:
101
+ """
102
+ Creates a ConfigFile from a JSON string.
103
+ """
104
+ return ConfigFile.from_json_str(data)
105
+
106
+
107
+ def dumps(config: ConfigFile) -> str:
108
+ """
109
+ Returns the JSON string representation of the ConfigFile.
110
+ """
111
+ return config.to_json_str()
@@ -7,20 +7,22 @@ from json import JSONDecodeError
7
7
  from pathlib import Path
8
8
  from typing import Optional, Tuple
9
9
 
10
+ from pydantic import ValidationError
10
11
  from rich.console import Console
11
12
 
12
13
  from cli.errors import ErrorPrinter
13
- from cli.settings import config_file, token_file
14
+ from cli.settings import config_file as cf
15
+ from cli.settings import state_file as sf
14
16
  from cli.settings import token_file as tf
15
17
  from cli.settings.config_file import ConfigFile
18
+ from cli.settings.state_file import StateFile
16
19
  from cli.settings.token_file import TokenFile
17
20
 
18
21
  err_console = Console(stderr=True)
19
22
 
20
-
21
23
  CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
22
24
  CLI_CONFIG_FILE_NAME = "config.json"
23
-
25
+ CLI_INTERNAL_STATE_FILE_NAME = "app-state.json"
24
26
 
25
27
  TokenFileMetadata = Tuple[TokenFile, Path]
26
28
 
@@ -36,6 +38,13 @@ class TokenNotFoundError(Exception):
36
38
  class Settings:
37
39
  """
38
40
  Settings handles tokens and other config for the remotive CLI
41
+
42
+ TODO: return None instead of raising errors when no active account is found
43
+ TODO: be consisten in how we update (and write) state to the different files
44
+ TODO: migrate away from singleton instance
45
+ TODO: what about manually downloaded tokens when removing a token?
46
+ TODO: what about active token when removing a token?
47
+ TODO: list tokens should use better listing logic
39
48
  """
40
49
 
41
50
  config_dir: Path
@@ -46,22 +55,28 @@ class Settings:
46
55
  self.config_file_path = self.config_dir / CLI_CONFIG_FILE_NAME
47
56
  if not self.config_file_path.exists():
48
57
  self._write_config_file(ConfigFile())
58
+ self.state_dir = self.config_dir / "state"
59
+ self.state_file_path = self.state_dir / CLI_INTERNAL_STATE_FILE_NAME
60
+ if not self.state_file_path.exists():
61
+ self._write_state_file(StateFile())
62
+
63
+ def get_cli_config(self) -> ConfigFile:
64
+ return self._read_config_file()
65
+
66
+ def get_state_file(self) -> StateFile:
67
+ return self._read_state_file()
49
68
 
50
69
  def set_default_organisation(self, organisation: str) -> None:
51
- cli_config = self.get_cli_config()
52
- try:
53
- token = settings.get_active_token_file()
54
- cli_config.set_account_field(token.account.email, organisation)
55
- self._write_config_file(cli_config)
56
- except TokenNotFoundError:
70
+ """
71
+ Set the default organization for the active account
72
+ """
73
+ config = self.get_cli_config()
74
+ active_account = config.get_active_account()
75
+ if not active_account:
57
76
  ErrorPrinter.print_hint("You must have an account activated in order to set default organization")
58
77
  sys.exit(1)
59
-
60
- def get_cli_config(self) -> ConfigFile:
61
- try:
62
- return self._read_config_file()
63
- except TokenNotFoundError:
64
- return ConfigFile()
78
+ active_account.default_organization = organisation
79
+ self._write_config_file(config)
65
80
 
66
81
  def get_active_token(self) -> str:
67
82
  """
@@ -74,12 +89,12 @@ class Settings:
74
89
  """
75
90
  Get the current active token file
76
91
  """
77
- active_account = self.get_cli_config().get_active()
78
- if active_account is not None:
79
- token_file_name = active_account.credentials_file
80
- return self._read_token_file(self.config_dir / token_file_name)
92
+ active_account = self.get_cli_config().get_active_account()
93
+ if not active_account:
94
+ raise TokenNotFoundError("No active account found")
81
95
 
82
- raise TokenNotFoundError
96
+ token_file_name = active_account.credentials_file
97
+ return self._read_token_file(self.config_dir / token_file_name)
83
98
 
84
99
  def activate_token(self, token_file: TokenFile) -> None:
85
100
  """
@@ -87,9 +102,9 @@ class Settings:
87
102
 
88
103
  The token secret will be set as the current active secret.
89
104
  """
90
- cli_config = self.get_cli_config()
91
- cli_config.activate(token_file.account.email)
92
- self._write_config_file(cli_config)
105
+ config = self.get_cli_config()
106
+ config.activate_account(token_file.account.email)
107
+ self._write_config_file(config)
93
108
 
94
109
  def clear_active_token(self) -> None:
95
110
  """
@@ -210,11 +225,19 @@ class Settings:
210
225
  """
211
226
  return [f[1] for f in self._list_service_account_tokens()]
212
227
 
228
+ def set_last_update_check_time(self, timestamp: str) -> None:
229
+ """
230
+ Sets the timestamp of the last self update check
231
+ """
232
+ state = self._read_state_file()
233
+ state.last_update_check_time = timestamp
234
+ self._write_state_file(state)
235
+
213
236
  def _list_personal_tokens(self) -> list[TokenFileMetadata]:
214
- return self._list_token_files(prefix=token_file.PERSONAL_TOKEN_FILE_PREFIX)
237
+ return self._list_token_files(prefix=tf.PERSONAL_TOKEN_FILE_PREFIX)
215
238
 
216
239
  def _list_service_account_tokens(self) -> list[TokenFileMetadata]:
217
- return self._list_token_files(prefix=token_file.SERVICE_ACCOUNT_TOKEN_FILE_PREFIX)
240
+ return self._list_token_files(prefix=tf.SERVICE_ACCOUNT_TOKEN_FILE_PREFIX)
218
241
 
219
242
  def _get_token_by_name(self, name: str) -> TokenFileMetadata:
220
243
  token_files = self._list_token_files()
@@ -234,7 +257,7 @@ class Settings:
234
257
  try:
235
258
  self._read_token_file(path)
236
259
  return True
237
- except JSONDecodeError:
260
+ except (JSONDecodeError, ValidationError):
238
261
  # TODO - this should be printed but printing it here causes it to be displayed to many times
239
262
  # err_console.print(f"File is not valid json, skipping. {path}")
240
263
  return False
@@ -258,38 +281,36 @@ class Settings:
258
281
  return tf.loads(data)
259
282
 
260
283
  def _read_config_file(self) -> ConfigFile:
261
- data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
262
- return config_file.loads(data)
284
+ data = self._read_file(self.config_file_path)
285
+ return cf.loads(data)
286
+
287
+ def _read_state_file(self) -> StateFile:
288
+ data = self._read_file(self.state_file_path)
289
+ return sf.loads(data)
263
290
 
264
291
  def _read_file(self, path: Path) -> str:
265
292
  if not path.exists():
266
- raise TokenNotFoundError(f"File could not be found: {path}")
293
+ raise FileNotFoundError(f"File could not be found: {path}")
267
294
  return path.read_text(encoding="utf-8")
268
295
 
269
296
  def _write_token_file(self, path: Path, token: TokenFile) -> Path:
270
297
  data = tf.dumps(token)
271
- path = self._write_file(path, data)
272
- os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
273
- return path
274
-
275
- # Temporary function while considering how to solve this
276
- def write_config_file(self, config: ConfigFile) -> Path:
277
- return self._write_config_file(config)
298
+ return self._write_file(path, data)
278
299
 
279
300
  def _write_config_file(self, config: ConfigFile) -> Path:
280
- """
281
- TODO: add read cache to avoid parsing the config every time we read it
282
- """
283
- data = config_file.dumps(config)
284
- path = self._write_file(self.config_file_path, data)
285
- os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
286
- return path
301
+ data = cf.dumps(config)
302
+ return self._write_file(self.config_file_path, data)
303
+
304
+ def _write_state_file(self, state: StateFile) -> Path:
305
+ data = sf.dumps(state)
306
+ return self._write_file(self.state_file_path, data)
287
307
 
288
308
  def _write_file(self, path: Path, data: str) -> Path:
289
309
  if self.config_dir not in path.parents:
290
310
  raise InvalidSettingsFilePathError(f"file {path} not in settings dir {self.config_dir}")
291
311
  path.parent.mkdir(parents=True, exist_ok=True)
292
312
  path.write_text(data, encoding="utf8")
313
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
293
314
  return path
294
315
 
295
316
 
@@ -5,8 +5,6 @@ import sys
5
5
  from pathlib import Path
6
6
  from typing import Any, Optional
7
7
 
8
- from dacite import from_dict
9
-
10
8
  from cli.settings.config_file import ConfigFile, loads
11
9
  from cli.settings.core import Settings, TokenNotFoundError
12
10
  from cli.settings.migration.migration_tools import get_token_file
@@ -54,6 +52,6 @@ def migrate_config_file(path: Path, settings: Settings) -> ConfigFile:
54
52
  return loads(data)
55
53
 
56
54
  sys.stderr.write("Migrating old configuration format")
57
- migrated_config: ConfigFile = from_dict(data_class=ConfigFile, data=migrated_data)
58
- settings.write_config_file(migrated_config)
55
+ migrated_config: ConfigFile = ConfigFile.from_dict(migrated_data)
56
+ settings._write_config_file(migrated_config)
59
57
  return migrated_config
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Optional
5
+
6
+ from pydantic import BaseModel
7
+
8
+ from cli.utils.time import parse_datetime
9
+
10
+
11
+ class StateFile(BaseModel):
12
+ """
13
+ StateFile represents the state file for the CLI.
14
+
15
+ TODO: Should all setters return a new instance of the StateFile?
16
+ """
17
+
18
+ version: str = "1.0"
19
+ last_update_check_time: Optional[str] = None
20
+
21
+ def should_perform_update_check(self) -> bool:
22
+ """
23
+ Check if we should perform an update check.
24
+
25
+ Returns True if the last update check time is older than 2 hours.
26
+ """
27
+ if not self.last_update_check_time:
28
+ return True # Returning True will trigger a check, which will properly set last_update_check_time
29
+
30
+ seconds = (datetime.now() - parse_datetime(self.last_update_check_time)).seconds
31
+ return (seconds / 3600) > 2
32
+
33
+ @classmethod
34
+ def from_json_str(cls, data: str) -> StateFile:
35
+ return cls.model_validate_json(data)
36
+
37
+ @classmethod
38
+ def from_dict(cls, data: dict[str, Any]) -> StateFile:
39
+ return cls.model_validate(data)
40
+
41
+ def to_json_str(self) -> str:
42
+ return self.model_dump_json()
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ return self.model_dump()
46
+
47
+
48
+ def loads(data: str) -> StateFile:
49
+ """
50
+ Creates a StateFile from a JSON string.
51
+ """
52
+ return StateFile.from_json_str(data)
53
+
54
+
55
+ def dumps(state: StateFile) -> str:
56
+ """
57
+ Returns the JSON string representation of the StateFile.
58
+ """
59
+ return state.to_json_str()
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date, datetime
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
8
+
9
+ from cli.utils.time import parse_date
10
+
11
+ DEFAULT_EMAIL = "unknown@remotivecloud.com"
12
+ PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
13
+ SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
14
+
15
+ TokenType = Literal["authorized_user", "service_account"]
16
+
17
+
18
+ def _email_to_safe_filename(email: str) -> str:
19
+ """Replace any invalid character with an underscore"""
20
+ return re.sub(r'[<>:"/\\|?*]', "_", email)
21
+
22
+
23
+ def _parse_token_type(token: str) -> TokenType:
24
+ if token.startswith("pa"):
25
+ return "authorized_user"
26
+ if token.startswith("sa"):
27
+ return "service_account"
28
+ raise ValueError(f"Unknown token type for token: {token}")
29
+
30
+
31
+ class TokenFileAccount(BaseModel):
32
+ """
33
+ TokenFileAccount represents the account information for a token file.
34
+ """
35
+
36
+ email: EmailStr = DEFAULT_EMAIL
37
+
38
+
39
+ class TokenFile(BaseModel):
40
+ """
41
+ TokenFile represents a token file for the CLI.
42
+
43
+ TODO: Should all setters return a new instance of the TokenFile?
44
+ """
45
+
46
+ version: str = "1.0"
47
+ type: TokenType
48
+ name: str
49
+ token: str
50
+ created: date
51
+ expires: date
52
+ account: TokenFileAccount = Field(default_factory=TokenFileAccount)
53
+
54
+ @field_validator("created", "expires", mode="before")
55
+ @classmethod
56
+ def _validate_parse_date(cls, value: str | date) -> date:
57
+ if isinstance(value, date):
58
+ return value
59
+ return parse_date(value)
60
+
61
+ @model_validator(mode="before")
62
+ @classmethod
63
+ def _validate_json_data(cls, json_data: Any) -> Any:
64
+ """
65
+ Try to migrate old formats and missing fields as best we can.
66
+
67
+ NOTE: If we ever need to add a new version (like 2.0), we should add explicit classes for each version (e.g. TokenFileV1,
68
+ TokenFileV2, etc.), each with their own fields. This will allow us to migrate to new versions without breaking
69
+ backwards compatibility.
70
+ """
71
+ if not isinstance(json_data, dict):
72
+ return json_data
73
+
74
+ if "version" not in json_data:
75
+ json_data["version"] = "1.0"
76
+
77
+ if "type" not in json_data and "token" in json_data:
78
+ json_data["type"] = _parse_token_type(json_data["token"])
79
+
80
+ if "account" not in json_data:
81
+ json_data["account"] = {"email": DEFAULT_EMAIL}
82
+ elif isinstance(json_data["account"], str):
83
+ json_data["account"] = {"email": json_data["account"]}
84
+
85
+ return json_data
86
+
87
+ def get_token_file_name(self) -> str:
88
+ """
89
+ Returns the name of the token_file following a predictable naming format.
90
+ """
91
+ email = _email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
92
+ if self.type == "authorized_user":
93
+ return f"{PERSONAL_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
94
+ return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
95
+
96
+ def is_expired(self) -> bool:
97
+ return datetime.today().date() > self.expires
98
+
99
+ def expires_in_days(self) -> int:
100
+ return (self.expires - datetime.today().date()).days
101
+
102
+ @classmethod
103
+ def from_json_str(cls, data: str) -> TokenFile:
104
+ return cls.model_validate_json(data)
105
+
106
+ @classmethod
107
+ def from_dict(cls, data: dict[str, Any]) -> TokenFile:
108
+ return cls.model_validate(data)
109
+
110
+ def to_json_str(self) -> str:
111
+ return self.model_dump_json()
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ return self.model_dump()
115
+
116
+
117
+ def loads(data: str) -> TokenFile:
118
+ """
119
+ Creates a TokenFile from a JSON string.
120
+ """
121
+ return TokenFile.from_json_str(data)
122
+
123
+
124
+ def dumps(token_file: TokenFile) -> str:
125
+ """
126
+ Returns the JSON string representation of the TokenFile.
127
+ """
128
+ return token_file.to_json_str()
@@ -84,7 +84,8 @@ def start_trial(
84
84
  ErrorPrinter.print_generic_message("Your current active credentials are not valid")
85
85
  raise typer.Exit(1)
86
86
 
87
- if organization is None and settings.get_cli_config().get_active_default_organisation() is None:
87
+ active_account = settings.get_cli_config().get_active_account()
88
+ if active_account and not organization and not active_account.default_organization:
88
89
  ErrorPrinter.print_hint("You have not specified any organization and no default organization is set")
89
90
  raise typer.Exit(1)
90
91
 
@@ -17,7 +17,7 @@ console = Console()
17
17
  def create_typer(**kwargs: Any) -> typer.Typer:
18
18
  """Create a Typer instance with default settings."""
19
19
  # return typer.Typer(no_args_is_help=True, **kwargs)
20
- return typer.Typer(cls=OrderCommands, no_args_is_help=True, **kwargs)
20
+ return typer.Typer(cls=OrderCommands, no_args_is_help=True, invoke_without_command=True, **kwargs)
21
21
 
22
22
 
23
23
  def print_padded(label: str, right_text: str, length: int = 30) -> None:
@@ -17,6 +17,7 @@ from rich.progress import Progress, SpinnerColumn, TextColumn, wrap_file
17
17
 
18
18
  from cli.errors import ErrorPrinter
19
19
  from cli.settings import TokenNotFoundError, settings
20
+ from cli.utils import versions
20
21
 
21
22
  err_console = Console(stderr=True)
22
23
 
@@ -45,14 +46,7 @@ class RestHelper:
45
46
  if "cloud-dev" in __base_url:
46
47
  __license_server_base_url = "https://license.cloud-dev.remotivelabs.com"
47
48
 
48
- # if 'REMOTIVE_CLOUD_AUTH_TOKEN' not in os.environ:
49
- # print('export REMOTIVE_CLOUD_AUTH_TOKEN=auth must be set')
50
- # exit(0)
51
-
52
- # token = os.environ["REMOTIVE_CLOUD_AUTH_TOKEN"]
53
- # headers = {"Authorization": "Bearer " + token}
54
-
55
- __headers: Dict[str, str] = {"User-Agent": f"remotivelabs-cli/{version('remotivelabs-cli')}"}
49
+ __headers: Dict[str, str] = {"User-Agent": f"remotivelabs-cli/{versions.cli_version()} ({versions.platform_info()})"}
56
50
  __org: str = ""
57
51
 
58
52
  __token: str = ""
@@ -90,10 +84,13 @@ class RestHelper:
90
84
 
91
85
  @staticmethod
92
86
  def ensure_auth_token(quiet: bool = False, access_token: Optional[str] = None) -> None:
87
+ # TODO: remove this? We already set the default organization as env in remotive.py
93
88
  if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
94
- org = settings.get_cli_config().get_active_default_organisation()
95
- if org is not None:
96
- os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
89
+ active_account = settings.get_cli_config().get_active_account()
90
+ if active_account:
91
+ org = active_account.default_organization
92
+ if org:
93
+ os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
97
94
 
98
95
  token = None
99
96
 
@@ -0,0 +1,11 @@
1
+ from datetime import date, datetime
2
+
3
+
4
+ def parse_date(date_str: str) -> date:
5
+ return parse_datetime(date_str).date()
6
+
7
+
8
+ def parse_datetime(date_str: str) -> datetime:
9
+ """Required for pre 3.11"""
10
+ normalized = date_str.replace("Z", "+00:00")
11
+ return datetime.fromisoformat(normalized)
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import platform
7
+ import urllib.request
8
+ from datetime import timedelta
9
+ from importlib import metadata as importlib_metadata
10
+ from importlib.metadata import version as python_project_version
11
+
12
+ from packaging.version import InvalidVersion, Version
13
+
14
+ from cli.errors import ErrorPrinter
15
+ from cli.settings import Settings
16
+
17
+
18
+ def cli_version() -> str:
19
+ return python_project_version("remotivelabs-cli")
20
+
21
+
22
+ def python_version() -> str:
23
+ return platform.python_version()
24
+
25
+
26
+ def host_os() -> str:
27
+ return platform.system().lower() # 'linux', 'darwin', 'windows'
28
+
29
+
30
+ def host_env() -> str:
31
+ return "docker" if os.environ.get("RUNS_IN_DOCKER") else "native"
32
+
33
+
34
+ def platform_info() -> str:
35
+ return f"python {python_version()}; {host_os()}; {host_env()}"
36
+
37
+
38
+ def _pypi_latest(
39
+ project: str, *, include_prereleases: bool, timeout: float = 2.5, user_agent: str | None = None
40
+ ) -> tuple[str | None, str | None]:
41
+ """Return (latest_version, project_url) from PyPI, skipping yanked files."""
42
+ url = f"https://pypi.org/pypi/{project}/json"
43
+ headers = {"Accept": "application/json"}
44
+ if user_agent:
45
+ headers["User-Agent"] = user_agent
46
+ req = urllib.request.Request(url, headers=headers)
47
+
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
50
+ data = json.load(resp)
51
+ except Exception:
52
+ return None, None # network/404/etc.
53
+
54
+ releases = data.get("releases") or {}
55
+ candidates: list[Version] = []
56
+ for s, files in releases.items():
57
+ try:
58
+ v = Version(s)
59
+ except InvalidVersion:
60
+ continue
61
+ the_files = files or []
62
+ if any(f.get("yanked", False) for f in the_files):
63
+ continue
64
+ if (v.is_prerelease or v.is_devrelease) and not include_prereleases:
65
+ continue
66
+ candidates.append(v)
67
+
68
+ if not candidates:
69
+ return None, None
70
+
71
+ latest = str(max(candidates))
72
+ info = data.get("info") or {}
73
+ proj_url = info.get("project_url") or info.get("package_url") or f"https://pypi.org/project/{project}/"
74
+ return latest, proj_url
75
+
76
+
77
+ def _installed_version(distribution_name: str, fallback: str | None = None) -> str | None:
78
+ try:
79
+ return importlib_metadata.version(distribution_name)
80
+ except importlib_metadata.PackageNotFoundError:
81
+ return fallback
82
+
83
+
84
+ def check_for_update(settings: Settings) -> None:
85
+ # Make it possible to disable update check, i.e in CI
86
+ if os.environ.get("PYTHON_DISABLE_UPDATE_CHECK"):
87
+ return
88
+ project = "remotivelabs-cli"
89
+
90
+ # Determine current version
91
+ cur = cli_version() or _installed_version(project)
92
+ if not cur:
93
+ return # unknown version → skip silently
94
+
95
+ state = settings.get_state_file()
96
+ if not state.last_update_check_time:
97
+ if os.environ.get("RUNS_IN_DOCKER"):
98
+ # To prevent that we always check update in docker due to ephemeral disks we write an "old" check if the state
99
+ # is missing. If no disk is mounted we will never get the update check but if its mounted properly we will get
100
+ # it on the second attempt. This is good enough
101
+ last_update_check_time = (datetime.datetime.now() - timedelta(hours=10)).isoformat()
102
+ settings.set_last_update_check_time(last_update_check_time)
103
+ return
104
+
105
+ elif not state.should_perform_update_check():
106
+ return
107
+
108
+ # We end up here if last_update_check_time is None or should_perform_update_check is true
109
+ include_prereleases = Version(cur).is_prerelease or Version(cur).is_devrelease
110
+
111
+ latest, proj_url = _pypi_latest(
112
+ project, include_prereleases=include_prereleases, user_agent=f"{project}/{cur} (+https://pypi.org/project/{project}/)"
113
+ )
114
+ if latest:
115
+ if Version(latest) > Version(cur):
116
+ _print_update_info(
117
+ cur,
118
+ latest,
119
+ )
120
+ settings.set_last_update_check_time(datetime.datetime.now().isoformat())
121
+
122
+
123
+ def _print_update_info(cur: str, latest: str) -> None:
124
+ instructions = (
125
+ "upgrade with: docker pull remotivelabs/remotivelabs-cli"
126
+ if os.environ.get("RUNS_IN_DOCKER")
127
+ else "upgrade with: pipx install -U remotivelabs-cli"
128
+ )
129
+
130
+ ErrorPrinter.print_hint(
131
+ f"Update available: remotivelabs-cli {cur} → {latest} , ({instructions}) we always recommend to use latest version"
132
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "remotivelabs-cli"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "CLI for operating RemotiveCloud and RemotiveBroker"
5
5
  authors = ["Johan Rask <johan.rask@remotivelabs.com>"]
6
6
  readme = "README.md"
@@ -26,7 +26,8 @@ python-can = ">=4.3.1"
26
26
  grpc-stubs = ">=1.53.0.5"
27
27
  mypy-protobuf = ">=3.0.0"
28
28
  types-requests = "^2.32.0.20240622"
29
- dacite = "^1.9.2"
29
+ pydantic = "^2.11.7"
30
+ email-validator = "^2.2.0"
30
31
 
31
32
  [tool.poetry.group.test.dependencies]
32
33
  pytest = "^8.3"
@@ -1,93 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import dataclasses
4
- import json
5
- from dataclasses import dataclass
6
- from typing import Any, Optional
7
-
8
- from dacite import from_dict
9
-
10
- from cli.settings.token_file import TokenFile
11
-
12
-
13
- def loads(data: str) -> ConfigFile:
14
- d = json.loads(data)
15
- return from_dict(ConfigFile, d)
16
-
17
-
18
- def dumps(config: ConfigFile) -> str:
19
- return json.dumps(dataclasses.asdict(config), default=str)
20
-
21
-
22
- @dataclass
23
- class Account:
24
- credentials_file: str
25
- default_organization: Optional[str] = None
26
- # Add project as well
27
-
28
-
29
- @dataclass
30
- class ConfigFile:
31
- version: str = "1.0"
32
- active: Optional[str] = None
33
- accounts: dict[str, Account] = dataclasses.field(default_factory=dict)
34
-
35
- def get_active_default_organisation(self) -> Optional[str]:
36
- active_account = self.get_active()
37
- return active_account.default_organization if active_account else None
38
-
39
- def get_active(self) -> Optional[Account]:
40
- if not self.active:
41
- return None
42
- account = self.get_account(self.active)
43
- if not account:
44
- raise KeyError(f"Activated account {self.active} is not a valid account")
45
- return account
46
-
47
- def activate(self, email: str) -> None:
48
- account = self.get_account(email)
49
- if not account:
50
- raise KeyError(f"Account {email} does not exists")
51
- self.active = email
52
-
53
- def get_account(self, email: str) -> Optional[Account]:
54
- if not self.accounts:
55
- return None
56
- return self.accounts.get(email, None)
57
-
58
- def remove_account(self, email: str) -> None:
59
- if self.accounts:
60
- self.accounts.pop(email, None)
61
-
62
- def init_account(self, email: str, token_file: TokenFile) -> None:
63
- if self.accounts is None:
64
- self.accounts = {}
65
-
66
- account = self.get_account(email)
67
- if not account:
68
- account = Account(credentials_file=token_file.get_token_file_name())
69
- else:
70
- account.credentials_file = token_file.get_token_file_name()
71
- self.accounts[email] = account
72
-
73
- def set_account_field(self, email: str, default_organization: Optional[str] = None) -> ConfigFile:
74
- if self.accounts is None:
75
- self.accounts = {}
76
-
77
- account = self.get_account(email)
78
- if not account:
79
- raise KeyError(f"Account with email {email} has not been initialized with token")
80
-
81
- # Update only fields explicitly passed
82
- if default_organization is not None:
83
- account.default_organization = default_organization
84
-
85
- return self
86
-
87
- @staticmethod
88
- def from_json_str(data: str) -> ConfigFile:
89
- return loads(data)
90
-
91
- @staticmethod
92
- def from_dict(data: dict[str, Any]) -> ConfigFile:
93
- return from_dict(ConfigFile, data)
@@ -1,101 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import dataclasses
4
- import json
5
- import re
6
- from dataclasses import dataclass
7
- from datetime import date, datetime
8
- from typing import Any, Literal
9
-
10
- DEFAULT_EMAIL = "unknown@remotivecloud.com"
11
- PERSONAL_TOKEN_FILE_PREFIX = "personal-token-"
12
- SERVICE_ACCOUNT_TOKEN_FILE_PREFIX = "service-account-token-"
13
-
14
- TokenType = Literal["authorized_user", "service_account"]
15
-
16
-
17
- def _parse_date(date_str: str) -> date:
18
- normalized = date_str.replace("Z", "+00:00")
19
- return datetime.fromisoformat(normalized).date()
20
-
21
-
22
- def _parse_token_type(token: str) -> TokenType:
23
- if token.startswith("pa"):
24
- return "authorized_user"
25
- if token.startswith("sa"):
26
- return "service_account"
27
- raise ValueError(f"Unknown token type for token: {token}")
28
-
29
-
30
- def _from_dict(d: dict[str, Any]) -> TokenFile:
31
- if "version" not in d:
32
- token_type = _parse_token_type(d["token"])
33
- return TokenFile(
34
- version="1.0",
35
- type=token_type,
36
- name=d["name"],
37
- token=d["token"],
38
- created=_parse_date(d["created"]),
39
- expires=_parse_date(d["expires"]),
40
- account=TokenFileAccount(email=DEFAULT_EMAIL),
41
- )
42
-
43
- account_email = d.get("account", {}).get("email", DEFAULT_EMAIL)
44
- return TokenFile(
45
- version=d["version"],
46
- type=d["type"],
47
- name=d["name"],
48
- token=d["token"],
49
- created=_parse_date(d["created"]),
50
- expires=_parse_date(d["expires"]),
51
- account=TokenFileAccount(email=account_email),
52
- )
53
-
54
-
55
- def loads(data: str) -> TokenFile:
56
- return _from_dict(json.loads(data))
57
-
58
-
59
- def dumps(token: TokenFile) -> str:
60
- return json.dumps(dataclasses.asdict(token), default=str)
61
-
62
-
63
- @dataclass
64
- class TokenFileAccount:
65
- email: str
66
-
67
-
68
- @dataclass
69
- class TokenFile:
70
- version: str
71
- type: TokenType
72
- name: str
73
- token: str
74
- created: date
75
- expires: date
76
- account: TokenFileAccount
77
-
78
- def get_token_file_name(self) -> str:
79
- def email_to_safe_filename(email: str) -> str:
80
- # Replace any invalid character with an underscore
81
- return re.sub(r'[<>:"/\\|?*]', "_", email)
82
-
83
- # From now, user will never be None when adding a token so in this case token_file.user is never None
84
- email = email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
85
- if self.type == "authorized_user":
86
- return f"{PERSONAL_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
87
- return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
88
-
89
- def is_expired(self) -> bool:
90
- return datetime.today().date() > self.expires
91
-
92
- def expires_in_days(self) -> int:
93
- return (self.expires - datetime.today().date()).days
94
-
95
- @staticmethod
96
- def from_json_str(data: str) -> TokenFile:
97
- return loads(data)
98
-
99
- @staticmethod
100
- def from_dict(data: dict[str, Any]) -> TokenFile:
101
- return _from_dict(data)