remotivelabs-cli 0.2.1__tar.gz → 0.2.2__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 (67) hide show
  1. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/PKG-INFO +3 -1
  2. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/remotive.py +7 -2
  3. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/core.py +17 -3
  4. remotivelabs_cli-0.2.2/cli/settings/state_file.py +32 -0
  5. remotivelabs_cli-0.2.2/cli/settings/token_file.py +136 -0
  6. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/typer/typer_utils.py +1 -1
  7. remotivelabs_cli-0.2.2/cli/utils/time.py +11 -0
  8. remotivelabs_cli-0.2.2/cli/utils/version_check.py +112 -0
  9. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/pyproject.toml +3 -1
  10. remotivelabs_cli-0.2.1/cli/settings/token_file.py +0 -101
  11. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/LICENSE +0 -0
  12. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/README.md +0 -0
  13. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/.DS_Store +0 -0
  14. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/__init__.py +0 -0
  15. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/api/cloud/tokens.py +0 -0
  16. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/brokers.py +0 -0
  17. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/export.py +0 -0
  18. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/files.py +0 -0
  19. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/lib/__about__.py +0 -0
  20. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/lib/broker.py +0 -0
  21. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/license_flows.py +0 -0
  22. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/licenses.py +0 -0
  23. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/playback.py +0 -0
  24. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/record.py +0 -0
  25. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/scripting.py +0 -0
  26. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/signals.py +0 -0
  27. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/__init__.py +0 -0
  28. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/__init__.py +0 -0
  29. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/cmd.py +0 -0
  30. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/login.py +0 -0
  31. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth_tokens.py +0 -0
  32. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/brokers.py +0 -0
  33. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/cloud_cli.py +0 -0
  34. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/configs.py +0 -0
  35. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/organisations.py +0 -0
  36. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/projects.py +0 -0
  37. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/recordings.py +0 -0
  38. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/recordings_playback.py +0 -0
  39. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/resumable_upload.py +0 -0
  40. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/sample_recordings.py +0 -0
  41. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/service_account_tokens.py +0 -0
  42. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/service_accounts.py +0 -0
  43. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/__init__.py +0 -0
  44. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/cmd.py +0 -0
  45. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/copy.py +0 -0
  46. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/uri_or_path.py +0 -0
  47. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/uri.py +0 -0
  48. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/__init__.py +0 -0
  49. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/connect.py +0 -0
  50. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/protopie/protopie.py +0 -0
  51. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/errors.py +0 -0
  52. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/__init__.py +0 -0
  53. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/config_file.py +0 -0
  54. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/__init__.py +0 -0
  55. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_all_token_files.py +0 -0
  56. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_config_file.py +0 -0
  57. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_legacy_dirs.py +0 -0
  58. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_token_file.py +0 -0
  59. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migration_tools.py +0 -0
  60. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/__init__.py +0 -0
  61. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/can/__init__.py +0 -0
  62. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/can/can.py +0 -0
  63. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/tools.py +0 -0
  64. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/topology/cmd.py +0 -0
  65. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/typer/__init__.py +0 -0
  66. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/utils/__init__.py +0 -0
  67. {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/utils/rest_helper.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.2
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
@@ -13,9 +13,11 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Requires-Dist: click (<8.2.0)
15
15
  Requires-Dist: dacite (>=1.9.2,<2.0.0)
16
+ Requires-Dist: email-validator (>=2.2.0,<3.0.0)
16
17
  Requires-Dist: grpc-stubs (>=1.53.0.5)
17
18
  Requires-Dist: mypy-protobuf (>=3.0.0)
18
19
  Requires-Dist: plotext (>=5.2,<6.0)
20
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
19
21
  Requires-Dist: pyjwt (>=2.6,<3.0)
20
22
  Requires-Dist: python-can (>=4.3.1)
21
23
  Requires-Dist: python-socketio (>=4.6.1)
@@ -20,6 +20,7 @@ from cli.settings.migration.migrate_legacy_dirs import migrate_legacy_settings_d
20
20
  from cli.tools.tools import app as tools_app
21
21
  from cli.topology.cmd import app as topology_app
22
22
  from cli.typer import typer_utils
23
+ from cli.utils.version_check import check_for_update
23
24
 
24
25
  err_console = Console(stderr=True)
25
26
 
@@ -46,7 +47,6 @@ def version_callback(value: bool) -> None:
46
47
  if value:
47
48
  my_version = version("remotivelabs-cli")
48
49
  typer.echo(my_version)
49
- raise typer.Exit()
50
50
 
51
51
 
52
52
  def test_callback(value: int) -> None:
@@ -55,6 +55,10 @@ def test_callback(value: int) -> None:
55
55
  raise typer.Exit()
56
56
 
57
57
 
58
+ def check_for_newer_version(settings: Settings) -> None:
59
+ check_for_update("remotivelabs-cli", version("remotivelabs-cli"), settings)
60
+
61
+
58
62
  def run_migrations(settings: Settings) -> None:
59
63
  """
60
64
  Run all migration scripts.
@@ -91,11 +95,12 @@ def main(
91
95
  None,
92
96
  "--version",
93
97
  callback=version_callback,
94
- is_eager=False,
98
+ is_eager=True,
95
99
  help="Print current version",
96
100
  ),
97
101
  ) -> None:
98
102
  run_migrations(settings)
103
+ check_for_newer_version(settings)
99
104
  set_default_org_as_env(settings)
100
105
  # Do other global stuff, handle other global options here
101
106
 
@@ -7,20 +7,21 @@ 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
14
  from cli.settings import config_file, token_file
14
15
  from cli.settings import token_file as tf
15
16
  from cli.settings.config_file import ConfigFile
17
+ from cli.settings.state_file import StateFile
16
18
  from cli.settings.token_file import TokenFile
17
19
 
18
20
  err_console = Console(stderr=True)
19
21
 
20
-
21
22
  CONFIG_DIR_PATH = Path.home() / ".config" / "remotive"
22
23
  CLI_CONFIG_FILE_NAME = "config.json"
23
-
24
+ CLI_INTERNAL_STATE_FILE_NAME = "app-state.json"
24
25
 
25
26
  TokenFileMetadata = Tuple[TokenFile, Path]
26
27
 
@@ -46,6 +47,10 @@ class Settings:
46
47
  self.config_file_path = self.config_dir / CLI_CONFIG_FILE_NAME
47
48
  if not self.config_file_path.exists():
48
49
  self._write_config_file(ConfigFile())
50
+ self.state_dir = self.config_dir / "state"
51
+ self.state_file_path = self.state_dir / CLI_INTERNAL_STATE_FILE_NAME
52
+ if not self.state_file_path.exists():
53
+ self.write_state_file(StateFile())
49
54
 
50
55
  def set_default_organisation(self, organisation: str) -> None:
51
56
  cli_config = self.get_cli_config()
@@ -166,6 +171,15 @@ class Settings:
166
171
 
167
172
  return file
168
173
 
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
+
169
183
  def list_personal_tokens(self) -> list[TokenFile]:
170
184
  """
171
185
  List all personal tokens
@@ -234,7 +248,7 @@ class Settings:
234
248
  try:
235
249
  self._read_token_file(path)
236
250
  return True
237
- except JSONDecodeError:
251
+ except (JSONDecodeError, ValidationError):
238
252
  # TODO - this should be printed but printing it here causes it to be displayed to many times
239
253
  # err_console.print(f"File is not valid json, skipping. {path}")
240
254
  return False
@@ -0,0 +1,32 @@
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)
@@ -0,0 +1,136 @@
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
+ email: EmailStr = DEFAULT_EMAIL
33
+
34
+
35
+ class TokenFile(BaseModel):
36
+ version: str = "1.0"
37
+ type: TokenType
38
+ name: str
39
+ token: str
40
+ created: date
41
+ expires: date
42
+ account: TokenFileAccount = Field(default_factory=TokenFileAccount)
43
+
44
+ @field_validator("created", "expires", mode="before")
45
+ @classmethod
46
+ def validate_parse_date(cls, value: str | date) -> date:
47
+ if isinstance(value, date):
48
+ return value
49
+ return parse_date(value)
50
+
51
+ @model_validator(mode="before")
52
+ @classmethod
53
+ def init_with_defaults(cls, json_data: Any) -> Any:
54
+ """
55
+ Try to migrate old formats and missing fields as best we can.
56
+
57
+ NOTE: If we ever need to add a new version (like 2.0), we should add explicit classes for each version (e.g. TokenFileV1,
58
+ TokenFileV2, etc.), each with their own fields. This will allow us to migrate to new versions without breaking
59
+ backwards compatibility.
60
+ """
61
+ if not isinstance(json_data, dict):
62
+ return json_data
63
+
64
+ if "version" not in json_data:
65
+ json_data["version"] = "1.0"
66
+
67
+ if "type" not in json_data and "token" in json_data:
68
+ json_data["type"] = _parse_token_type(json_data["token"])
69
+
70
+ if "account" not in json_data:
71
+ json_data["account"] = {"email": DEFAULT_EMAIL}
72
+ elif isinstance(json_data["account"], str):
73
+ json_data["account"] = {"email": json_data["account"]}
74
+
75
+ return json_data
76
+
77
+ def get_token_file_name(self) -> str:
78
+ """
79
+ Returns the name of the token file using the proper file name format.
80
+ """
81
+ email = _email_to_safe_filename(self.account.email) if self.account is not None else "unknown"
82
+ if self.type == "authorized_user":
83
+ return f"{PERSONAL_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
84
+ return f"{SERVICE_ACCOUNT_TOKEN_FILE_PREFIX}{self.name}-{email}.json"
85
+
86
+ def is_expired(self) -> bool:
87
+ """
88
+ Returns True if the token is expired, False otherwise.
89
+ """
90
+ return datetime.today().date() > self.expires
91
+
92
+ def expires_in_days(self) -> int:
93
+ """
94
+ Returns the number of days until the token expires.
95
+ """
96
+ return (self.expires - datetime.today().date()).days
97
+
98
+ @classmethod
99
+ def from_json_str(cls, data: str) -> TokenFile:
100
+ """
101
+ Creates a TokenFile from a JSON string.
102
+ """
103
+ return cls.model_validate_json(data)
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> TokenFile:
107
+ """
108
+ Creates a TokenFile from a dictionary.
109
+ """
110
+ return cls.model_validate(data)
111
+
112
+ def to_json_str(self) -> str:
113
+ """
114
+ Returns the JSON string representation of the TokenFile.
115
+ """
116
+ return self.model_dump_json()
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ """
120
+ Returns the dictionary representation of the TokenFile.
121
+ """
122
+ return self.model_dump()
123
+
124
+
125
+ def loads(data: str) -> TokenFile:
126
+ """
127
+ Creates a TokenFile from a JSON string.
128
+ """
129
+ return TokenFile.from_json_str(data)
130
+
131
+
132
+ def dumps(token_file: TokenFile) -> str:
133
+ """
134
+ Returns the JSON string representation of the TokenFile.
135
+ """
136
+ return token_file.to_json_str()
@@ -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:
@@ -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,112 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import urllib.request
7
+ from datetime import timedelta
8
+ from importlib import metadata as importlib_metadata
9
+
10
+ from packaging.version import InvalidVersion, Version
11
+
12
+ from cli.errors import ErrorPrinter
13
+ from cli.settings import Settings
14
+
15
+
16
+ def _pypi_latest(
17
+ project: str, *, include_prereleases: bool, timeout: float = 2.5, user_agent: str | None = None
18
+ ) -> tuple[str | None, str | None]:
19
+ """Return (latest_version, project_url) from PyPI, skipping yanked files."""
20
+ url = f"https://pypi.org/pypi/{project}/json"
21
+ headers = {"Accept": "application/json"}
22
+ if user_agent:
23
+ headers["User-Agent"] = user_agent
24
+ req = urllib.request.Request(url, headers=headers)
25
+
26
+ try:
27
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
28
+ data = json.load(resp)
29
+ except Exception:
30
+ return None, None # network/404/etc.
31
+
32
+ releases = data.get("releases") or {}
33
+ candidates: list[Version] = []
34
+ for s, files in releases.items():
35
+ try:
36
+ v = Version(s)
37
+ except InvalidVersion:
38
+ continue
39
+ the_files = files or []
40
+ if any(f.get("yanked", False) for f in the_files):
41
+ continue
42
+ if (v.is_prerelease or v.is_devrelease) and not include_prereleases:
43
+ continue
44
+ candidates.append(v)
45
+
46
+ if not candidates:
47
+ return None, None
48
+
49
+ latest = str(max(candidates))
50
+ info = data.get("info") or {}
51
+ proj_url = info.get("project_url") or info.get("package_url") or f"https://pypi.org/project/{project}/"
52
+ return latest, proj_url
53
+
54
+
55
+ def _installed_version(distribution_name: str, fallback: str | None = None) -> str | None:
56
+ try:
57
+ return importlib_metadata.version(distribution_name)
58
+ except importlib_metadata.PackageNotFoundError:
59
+ return fallback
60
+
61
+
62
+ def check_for_update(project: str, current_version: str, settings: Settings) -> None:
63
+ # Make it possible to disable update check, i.e in CI
64
+ if os.environ.get("PYTHON_DISABLE_UPDATE_CHECK"):
65
+ return
66
+
67
+ # Determine current version
68
+ cur = current_version or _installed_version(project)
69
+ if not cur:
70
+ return # unknown version → skip silently
71
+
72
+ state = settings.read_state_file()
73
+
74
+ if not state.last_update_check_time:
75
+ if os.environ.get("RUNS_IN_DOCKER"):
76
+ # To prevent that we always check update in docker due to ephemeral disks we write an "old" check if the state
77
+ # is missing. If no disk is mounted we will never get the update check but if its mounted properly we will get
78
+ # 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)
81
+ return
82
+
83
+ elif not state.should_perform_update_check():
84
+ return
85
+
86
+ # We end up here if last_update_check_time is None or should_perform_update_check is true
87
+
88
+ include_prereleases = Version(cur).is_prerelease or Version(cur).is_devrelease
89
+
90
+ latest, proj_url = _pypi_latest(
91
+ project, include_prereleases=include_prereleases, user_agent=f"{project}/{cur} (+https://pypi.org/project/{project}/)"
92
+ )
93
+ if latest:
94
+ if Version(latest) > Version(cur):
95
+ _print_update_info(
96
+ cur,
97
+ latest,
98
+ )
99
+ state.last_update_check_time = datetime.datetime.now().isoformat()
100
+ settings.write_state_file(state)
101
+
102
+
103
+ def _print_update_info(cur: str, latest: str) -> None:
104
+ instructions = (
105
+ "upgrade with: docker pull remotivelabs/remotivelabs-cli"
106
+ if os.environ.get("RUNS_IN_DOCKER")
107
+ else "upgrade with: pipx install -U remotivelabs-cli"
108
+ )
109
+
110
+ ErrorPrinter.print_hint(
111
+ f"Update available: remotivelabs-cli {cur} → {latest} , ({instructions}) we always recommend to use latest version"
112
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "remotivelabs-cli"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "CLI for operating RemotiveCloud and RemotiveBroker"
5
5
  authors = ["Johan Rask <johan.rask@remotivelabs.com>"]
6
6
  readme = "README.md"
@@ -27,6 +27,8 @@ grpc-stubs = ">=1.53.0.5"
27
27
  mypy-protobuf = ">=3.0.0"
28
28
  types-requests = "^2.32.0.20240622"
29
29
  dacite = "^1.9.2"
30
+ pydantic = "^2.11.7"
31
+ email-validator = "^2.2.0"
30
32
 
31
33
  [tool.poetry.group.test.dependencies]
32
34
  pytest = "^8.3"
@@ -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)