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.
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/PKG-INFO +3 -2
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth_tokens.py +4 -13
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/organisations.py +5 -6
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/remotive.py +11 -8
- remotivelabs_cli-0.2.3/cli/settings/config_file.py +111 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/core.py +64 -43
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_config_file.py +2 -4
- remotivelabs_cli-0.2.3/cli/settings/state_file.py +59 -0
- remotivelabs_cli-0.2.3/cli/settings/token_file.py +128 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/topology/cmd.py +2 -1
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/typer/typer_utils.py +1 -1
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/utils/rest_helper.py +8 -11
- remotivelabs_cli-0.2.3/cli/utils/time.py +11 -0
- remotivelabs_cli-0.2.3/cli/utils/versions.py +132 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/pyproject.toml +3 -2
- remotivelabs_cli-0.2.1/cli/settings/config_file.py +0 -93
- remotivelabs_cli-0.2.1/cli/settings/token_file.py +0 -101
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/LICENSE +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/README.md +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/.DS_Store +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/api/cloud/tokens.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/brokers.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/export.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/files.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/lib/__about__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/lib/broker.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/license_flows.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/licenses.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/playback.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/record.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/scripting.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/broker/signals.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/cmd.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/auth/login.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/brokers.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/cloud_cli.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/configs.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/projects.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/recordings.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/recordings_playback.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/resumable_upload.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/sample_recordings.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/service_account_tokens.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/service_accounts.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/cmd.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/copy.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/storage/uri_or_path.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/cloud/uri.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/connect.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/connect/protopie/protopie.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/errors.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_all_token_files.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_legacy_dirs.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_token_file.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migration_tools.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/can/__init__.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/can/can.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/tools/tools.py +0 -0
- {remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/typer/__init__.py +0 -0
- {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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
if
|
|
85
|
-
os.environ["REMOTIVE_CLOUD_ORGANIZATION"] =
|
|
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=
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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().
|
|
78
|
-
if
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
self._write_config_file(
|
|
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=
|
|
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=
|
|
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.
|
|
262
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_config_file.py
RENAMED
|
@@ -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(
|
|
58
|
-
settings.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
if
|
|
96
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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.3}/cli/settings/migration/migrate_all_token_files.py
RENAMED
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/cli/settings/migration/migrate_legacy_dirs.py
RENAMED
|
File without changes
|
{remotivelabs_cli-0.2.1 → remotivelabs_cli-0.2.3}/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
|