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.
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/PKG-INFO +3 -1
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/remotive.py +7 -2
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/core.py +17 -3
- remotivelabs_cli-0.2.2/cli/settings/state_file.py +32 -0
- remotivelabs_cli-0.2.2/cli/settings/token_file.py +136 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/typer/typer_utils.py +1 -1
- remotivelabs_cli-0.2.2/cli/utils/time.py +11 -0
- remotivelabs_cli-0.2.2/cli/utils/version_check.py +112 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/pyproject.toml +3 -1
- remotivelabs_cli-0.2.1/cli/settings/token_file.py +0 -101
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/LICENSE +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/README.md +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/.DS_Store +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/api/cloud/tokens.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/brokers.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/export.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/files.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/lib/__about__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/lib/broker.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/license_flows.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/licenses.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/playback.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/record.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/scripting.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/broker/signals.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/cmd.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth/login.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/auth_tokens.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/brokers.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/cloud_cli.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/configs.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/organisations.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/projects.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/recordings.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/recordings_playback.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/resumable_upload.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/sample_recordings.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/service_account_tokens.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/service_accounts.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/cmd.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/copy.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/storage/uri_or_path.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/cloud/uri.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/connect.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/connect/protopie/protopie.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/errors.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/config_file.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_all_token_files.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_config_file.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_legacy_dirs.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_token_file.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migration_tools.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/can/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/can/can.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/tools/tools.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/topology/cmd.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/typer/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/utils/__init__.py +0 -0
- {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.
|
|
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=
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_all_token_files.py
RENAMED
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_config_file.py
RENAMED
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_legacy_dirs.py
RENAMED
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.2}/cli/settings/migration/migrate_token_file.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|