remotivelabs-cli 0.2.2__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.2 → remotivelabs_cli-0.2.3}/PKG-INFO +1 -2
  2. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/auth_tokens.py +4 -13
  3. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/organisations.py +5 -6
  4. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/remotive.py +6 -8
  5. remotivelabs_cli-0.2.3/cli/settings/config_file.py +111 -0
  6. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/core.py +57 -50
  7. {remotivelabs_cli-0.2.2 → 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.2 → remotivelabs_cli-0.2.3}/cli/settings/token_file.py +13 -21
  10. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/topology/cmd.py +2 -1
  11. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/utils/rest_helper.py +8 -11
  12. remotivelabs_cli-0.2.2/cli/utils/version_check.py → remotivelabs_cli-0.2.3/cli/utils/versions.py +29 -9
  13. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/pyproject.toml +1 -2
  14. remotivelabs_cli-0.2.2/cli/settings/config_file.py +0 -93
  15. remotivelabs_cli-0.2.2/cli/settings/state_file.py +0 -32
  16. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/LICENSE +0 -0
  17. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/README.md +0 -0
  18. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/.DS_Store +0 -0
  19. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/__init__.py +0 -0
  20. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/api/cloud/tokens.py +0 -0
  21. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/brokers.py +0 -0
  22. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/export.py +0 -0
  23. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/files.py +0 -0
  24. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/lib/__about__.py +0 -0
  25. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/lib/broker.py +0 -0
  26. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/license_flows.py +0 -0
  27. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/licenses.py +0 -0
  28. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/playback.py +0 -0
  29. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/record.py +0 -0
  30. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/scripting.py +0 -0
  31. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/broker/signals.py +0 -0
  32. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/__init__.py +0 -0
  33. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/auth/__init__.py +0 -0
  34. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/auth/cmd.py +0 -0
  35. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/auth/login.py +0 -0
  36. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/brokers.py +0 -0
  37. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/cloud_cli.py +0 -0
  38. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/configs.py +0 -0
  39. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/projects.py +0 -0
  40. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/recordings.py +0 -0
  41. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/recordings_playback.py +0 -0
  42. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/resumable_upload.py +0 -0
  43. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/sample_recordings.py +0 -0
  44. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/service_account_tokens.py +0 -0
  45. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/service_accounts.py +0 -0
  46. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/storage/__init__.py +0 -0
  47. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/storage/cmd.py +0 -0
  48. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/storage/copy.py +0 -0
  49. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/storage/uri_or_path.py +0 -0
  50. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/cloud/uri.py +0 -0
  51. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/connect/__init__.py +0 -0
  52. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/connect/connect.py +0 -0
  53. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/connect/protopie/protopie.py +0 -0
  54. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/errors.py +0 -0
  55. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/__init__.py +0 -0
  56. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/migration/__init__.py +0 -0
  57. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_all_token_files.py +0 -0
  58. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_legacy_dirs.py +0 -0
  59. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_token_file.py +0 -0
  60. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/settings/migration/migration_tools.py +0 -0
  61. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/tools/__init__.py +0 -0
  62. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/tools/can/__init__.py +0 -0
  63. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/tools/can/can.py +0 -0
  64. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/tools/tools.py +0 -0
  65. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/typer/__init__.py +0 -0
  66. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/typer/typer_utils.py +0 -0
  67. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/utils/__init__.py +0 -0
  68. {remotivelabs_cli-0.2.2 → remotivelabs_cli-0.2.3}/cli/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: remotivelabs-cli
3
- Version: 0.2.2
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,7 +12,6 @@ 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)
16
15
  Requires-Dist: email-validator (>=2.2.0,<3.0.0)
17
16
  Requires-Dist: grpc-stubs (>=1.53.0.5)
18
17
  Requires-Dist: mypy-protobuf (>=3.0.0)
@@ -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,7 +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
23
- from cli.utils.version_check import check_for_update
22
+ from cli.utils import versions
24
23
 
25
24
  err_console = Console(stderr=True)
26
25
 
@@ -45,8 +44,7 @@ For documentation - https://docs.remotivelabs.com
45
44
 
46
45
  def version_callback(value: bool) -> None:
47
46
  if value:
48
- my_version = version("remotivelabs-cli")
49
- typer.echo(my_version)
47
+ typer.echo(f"remotivelabs-cli {versions.cli_version()} ({versions.platform_info()})")
50
48
 
51
49
 
52
50
  def test_callback(value: int) -> None:
@@ -56,7 +54,7 @@ def test_callback(value: int) -> None:
56
54
 
57
55
 
58
56
  def check_for_newer_version(settings: Settings) -> None:
59
- check_for_update("remotivelabs-cli", version("remotivelabs-cli"), settings)
57
+ versions.check_for_update(settings)
60
58
 
61
59
 
62
60
  def run_migrations(settings: Settings) -> None:
@@ -84,9 +82,9 @@ def set_default_org_as_env(settings: Settings) -> None:
84
82
  This has to be done early before it is read
85
83
  """
86
84
  if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
87
- org = settings.get_cli_config().get_active_default_organisation()
88
- if org is not None:
89
- 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
90
88
 
91
89
 
92
90
  @app.callback()
@@ -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()
@@ -11,7 +11,8 @@ from pydantic import ValidationError
11
11
  from rich.console import Console
12
12
 
13
13
  from cli.errors import ErrorPrinter
14
- 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
15
16
  from cli.settings import token_file as tf
16
17
  from cli.settings.config_file import ConfigFile
17
18
  from cli.settings.state_file import StateFile
@@ -37,6 +38,13 @@ class TokenNotFoundError(Exception):
37
38
  class Settings:
38
39
  """
39
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
40
48
  """
41
49
 
42
50
  config_dir: Path
@@ -50,23 +58,25 @@ class Settings:
50
58
  self.state_dir = self.config_dir / "state"
51
59
  self.state_file_path = self.state_dir / CLI_INTERNAL_STATE_FILE_NAME
52
60
  if not self.state_file_path.exists():
53
- self.write_state_file(StateFile())
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()
54
68
 
55
69
  def set_default_organisation(self, organisation: str) -> None:
56
- cli_config = self.get_cli_config()
57
- try:
58
- token = settings.get_active_token_file()
59
- cli_config.set_account_field(token.account.email, organisation)
60
- self._write_config_file(cli_config)
61
- 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:
62
76
  ErrorPrinter.print_hint("You must have an account activated in order to set default organization")
63
77
  sys.exit(1)
64
-
65
- def get_cli_config(self) -> ConfigFile:
66
- try:
67
- return self._read_config_file()
68
- except TokenNotFoundError:
69
- return ConfigFile()
78
+ active_account.default_organization = organisation
79
+ self._write_config_file(config)
70
80
 
71
81
  def get_active_token(self) -> str:
72
82
  """
@@ -79,12 +89,12 @@ class Settings:
79
89
  """
80
90
  Get the current active token file
81
91
  """
82
- active_account = self.get_cli_config().get_active()
83
- if active_account is not None:
84
- token_file_name = active_account.credentials_file
85
- 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")
86
95
 
87
- raise TokenNotFoundError
96
+ token_file_name = active_account.credentials_file
97
+ return self._read_token_file(self.config_dir / token_file_name)
88
98
 
89
99
  def activate_token(self, token_file: TokenFile) -> None:
90
100
  """
@@ -92,9 +102,9 @@ class Settings:
92
102
 
93
103
  The token secret will be set as the current active secret.
94
104
  """
95
- cli_config = self.get_cli_config()
96
- cli_config.activate(token_file.account.email)
97
- 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)
98
108
 
99
109
  def clear_active_token(self) -> None:
100
110
  """
@@ -171,15 +181,6 @@ class Settings:
171
181
 
172
182
  return file
173
183
 
174
- def read_state_file(self) -> StateFile:
175
- try:
176
- return StateFile.loads(self.state_file_path.read_text(encoding="utf-8"))
177
- except Exception:
178
- return StateFile()
179
-
180
- def write_state_file(self, state_file: StateFile) -> None:
181
- self._write_file(self.state_file_path, state_file.dumps())
182
-
183
184
  def list_personal_tokens(self) -> list[TokenFile]:
184
185
  """
185
186
  List all personal tokens
@@ -224,11 +225,19 @@ class Settings:
224
225
  """
225
226
  return [f[1] for f in self._list_service_account_tokens()]
226
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
+
227
236
  def _list_personal_tokens(self) -> list[TokenFileMetadata]:
228
- return self._list_token_files(prefix=token_file.PERSONAL_TOKEN_FILE_PREFIX)
237
+ return self._list_token_files(prefix=tf.PERSONAL_TOKEN_FILE_PREFIX)
229
238
 
230
239
  def _list_service_account_tokens(self) -> list[TokenFileMetadata]:
231
- 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)
232
241
 
233
242
  def _get_token_by_name(self, name: str) -> TokenFileMetadata:
234
243
  token_files = self._list_token_files()
@@ -272,38 +281,36 @@ class Settings:
272
281
  return tf.loads(data)
273
282
 
274
283
  def _read_config_file(self) -> ConfigFile:
275
- data = self._read_file(self.config_dir / CLI_CONFIG_FILE_NAME)
276
- 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)
277
290
 
278
291
  def _read_file(self, path: Path) -> str:
279
292
  if not path.exists():
280
- raise TokenNotFoundError(f"File could not be found: {path}")
293
+ raise FileNotFoundError(f"File could not be found: {path}")
281
294
  return path.read_text(encoding="utf-8")
282
295
 
283
296
  def _write_token_file(self, path: Path, token: TokenFile) -> Path:
284
297
  data = tf.dumps(token)
285
- path = self._write_file(path, data)
286
- os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
287
- return path
288
-
289
- # Temporary function while considering how to solve this
290
- def write_config_file(self, config: ConfigFile) -> Path:
291
- return self._write_config_file(config)
298
+ return self._write_file(path, data)
292
299
 
293
300
  def _write_config_file(self, config: ConfigFile) -> Path:
294
- """
295
- TODO: add read cache to avoid parsing the config every time we read it
296
- """
297
- data = config_file.dumps(config)
298
- path = self._write_file(self.config_file_path, data)
299
- os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
300
- 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)
301
307
 
302
308
  def _write_file(self, path: Path, data: str) -> Path:
303
309
  if self.config_dir not in path.parents:
304
310
  raise InvalidSettingsFilePathError(f"file {path} not in settings dir {self.config_dir}")
305
311
  path.parent.mkdir(parents=True, exist_ok=True)
306
312
  path.write_text(data, encoding="utf8")
313
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
307
314
  return path
308
315
 
309
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()
@@ -29,10 +29,20 @@ def _parse_token_type(token: str) -> TokenType:
29
29
 
30
30
 
31
31
  class TokenFileAccount(BaseModel):
32
+ """
33
+ TokenFileAccount represents the account information for a token file.
34
+ """
35
+
32
36
  email: EmailStr = DEFAULT_EMAIL
33
37
 
34
38
 
35
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
+
36
46
  version: str = "1.0"
37
47
  type: TokenType
38
48
  name: str
@@ -43,14 +53,14 @@ class TokenFile(BaseModel):
43
53
 
44
54
  @field_validator("created", "expires", mode="before")
45
55
  @classmethod
46
- def validate_parse_date(cls, value: str | date) -> date:
56
+ def _validate_parse_date(cls, value: str | date) -> date:
47
57
  if isinstance(value, date):
48
58
  return value
49
59
  return parse_date(value)
50
60
 
51
61
  @model_validator(mode="before")
52
62
  @classmethod
53
- def init_with_defaults(cls, json_data: Any) -> Any:
63
+ def _validate_json_data(cls, json_data: Any) -> Any:
54
64
  """
55
65
  Try to migrate old formats and missing fields as best we can.
56
66
 
@@ -76,7 +86,7 @@ class TokenFile(BaseModel):
76
86
 
77
87
  def get_token_file_name(self) -> str:
78
88
  """
79
- Returns the name of the token file using the proper file name format.
89
+ Returns the name of the token_file following a predictable naming format.
80
90
  """
81
91
  email = _email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
82
92
  if self.type == "authorized_user":
@@ -84,41 +94,23 @@ class TokenFile(BaseModel):
84
94
  return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
85
95
 
86
96
  def is_expired(self) -> bool:
87
- """
88
- Returns True if the token is expired, False otherwise.
89
- """
90
97
  return datetime.today().date() > self.expires
91
98
 
92
99
  def expires_in_days(self) -> int:
93
- """
94
- Returns the number of days until the token expires.
95
- """
96
100
  return (self.expires - datetime.today().date()).days
97
101
 
98
102
  @classmethod
99
103
  def from_json_str(cls, data: str) -> TokenFile:
100
- """
101
- Creates a TokenFile from a JSON string.
102
- """
103
104
  return cls.model_validate_json(data)
104
105
 
105
106
  @classmethod
106
107
  def from_dict(cls, data: dict[str, Any]) -> TokenFile:
107
- """
108
- Creates a TokenFile from a dictionary.
109
- """
110
108
  return cls.model_validate(data)
111
109
 
112
110
  def to_json_str(self) -> str:
113
- """
114
- Returns the JSON string representation of the TokenFile.
115
- """
116
111
  return self.model_dump_json()
117
112
 
118
113
  def to_dict(self) -> dict[str, Any]:
119
- """
120
- Returns the dictionary representation of the TokenFile.
121
- """
122
114
  return self.model_dump()
123
115
 
124
116
 
@@ -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,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
 
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  import datetime
4
4
  import json
5
5
  import os
6
+ import platform
6
7
  import urllib.request
7
8
  from datetime import timedelta
8
9
  from importlib import metadata as importlib_metadata
10
+ from importlib.metadata import version as python_project_version
9
11
 
10
12
  from packaging.version import InvalidVersion, Version
11
13
 
@@ -13,6 +15,26 @@ from cli.errors import ErrorPrinter
13
15
  from cli.settings import Settings
14
16
 
15
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
+
16
38
  def _pypi_latest(
17
39
  project: str, *, include_prereleases: bool, timeout: float = 2.5, user_agent: str | None = None
18
40
  ) -> tuple[str | None, str | None]:
@@ -59,32 +81,31 @@ def _installed_version(distribution_name: str, fallback: str | None = None) -> s
59
81
  return fallback
60
82
 
61
83
 
62
- def check_for_update(project: str, current_version: str, settings: Settings) -> None:
84
+ def check_for_update(settings: Settings) -> None:
63
85
  # Make it possible to disable update check, i.e in CI
64
86
  if os.environ.get("PYTHON_DISABLE_UPDATE_CHECK"):
65
87
  return
88
+ project = "remotivelabs-cli"
66
89
 
67
90
  # Determine current version
68
- cur = current_version or _installed_version(project)
91
+ cur = cli_version() or _installed_version(project)
69
92
  if not cur:
70
93
  return # unknown version → skip silently
71
94
 
72
- state = settings.read_state_file()
73
-
95
+ state = settings.get_state_file()
74
96
  if not state.last_update_check_time:
75
97
  if os.environ.get("RUNS_IN_DOCKER"):
76
98
  # To prevent that we always check update in docker due to ephemeral disks we write an "old" check if the state
77
99
  # is missing. If no disk is mounted we will never get the update check but if its mounted properly we will get
78
100
  # it on the second attempt. This is good enough
79
- state.last_update_check_time = (datetime.datetime.now() - timedelta(hours=10)).isoformat()
80
- settings.write_state_file(state)
101
+ last_update_check_time = (datetime.datetime.now() - timedelta(hours=10)).isoformat()
102
+ settings.set_last_update_check_time(last_update_check_time)
81
103
  return
82
104
 
83
105
  elif not state.should_perform_update_check():
84
106
  return
85
107
 
86
108
  # We end up here if last_update_check_time is None or should_perform_update_check is true
87
-
88
109
  include_prereleases = Version(cur).is_prerelease or Version(cur).is_devrelease
89
110
 
90
111
  latest, proj_url = _pypi_latest(
@@ -96,8 +117,7 @@ def check_for_update(project: str, current_version: str, settings: Settings) ->
96
117
  cur,
97
118
  latest,
98
119
  )
99
- state.last_update_check_time = datetime.datetime.now().isoformat()
100
- settings.write_state_file(state)
120
+ settings.set_last_update_check_time(datetime.datetime.now().isoformat())
101
121
 
102
122
 
103
123
  def _print_update_info(cur: str, latest: str) -> None:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "remotivelabs-cli"
3
- version = "0.2.2"
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,6 @@ 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"
30
29
  pydantic = "^2.11.7"
31
30
  email-validator = "^2.2.0"
32
31
 
@@ -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,32 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import dataclasses
4
- import json
5
- from dataclasses import dataclass
6
- from datetime import datetime
7
- from typing import Optional
8
-
9
- from dacite import from_dict
10
-
11
- from cli.utils.time import parse_datetime
12
-
13
-
14
- @dataclass
15
- class StateFile:
16
- version: str = "1.0"
17
- last_update_check_time: Optional[str] = None
18
-
19
- def dumps(self) -> str:
20
- return json.dumps(dataclasses.asdict(self), default=str)
21
-
22
- def should_perform_update_check(self) -> bool:
23
- if self.last_update_check_time:
24
- seconds = (datetime.now() - parse_datetime(self.last_update_check_time)).seconds
25
- return (seconds / 3600) > 2
26
- # This will solve the issue
27
- return True
28
-
29
- @staticmethod
30
- def loads(data: str) -> StateFile:
31
- d = json.loads(data)
32
- return from_dict(StateFile, d)