remotivelabs-cli 0.5.0a1__py3-none-any.whl

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.
Files changed (84) hide show
  1. remotivelabs/cli/__init__.py +0 -0
  2. remotivelabs/cli/api/cloud/tokens.py +62 -0
  3. remotivelabs/cli/broker/__init__.py +33 -0
  4. remotivelabs/cli/broker/defaults.py +1 -0
  5. remotivelabs/cli/broker/discovery.py +43 -0
  6. remotivelabs/cli/broker/export.py +92 -0
  7. remotivelabs/cli/broker/files.py +119 -0
  8. remotivelabs/cli/broker/lib/__about__.py +4 -0
  9. remotivelabs/cli/broker/lib/broker.py +625 -0
  10. remotivelabs/cli/broker/lib/client.py +224 -0
  11. remotivelabs/cli/broker/lib/helper.py +277 -0
  12. remotivelabs/cli/broker/lib/signalcreator.py +196 -0
  13. remotivelabs/cli/broker/license_flows.py +167 -0
  14. remotivelabs/cli/broker/licenses.py +98 -0
  15. remotivelabs/cli/broker/playback.py +117 -0
  16. remotivelabs/cli/broker/record.py +41 -0
  17. remotivelabs/cli/broker/recording_session/__init__.py +3 -0
  18. remotivelabs/cli/broker/recording_session/client.py +67 -0
  19. remotivelabs/cli/broker/recording_session/cmd.py +254 -0
  20. remotivelabs/cli/broker/recording_session/time.py +49 -0
  21. remotivelabs/cli/broker/scripting.py +129 -0
  22. remotivelabs/cli/broker/signals.py +220 -0
  23. remotivelabs/cli/broker/version.py +31 -0
  24. remotivelabs/cli/cloud/__init__.py +17 -0
  25. remotivelabs/cli/cloud/auth/__init__.py +3 -0
  26. remotivelabs/cli/cloud/auth/cmd.py +128 -0
  27. remotivelabs/cli/cloud/auth/login.py +283 -0
  28. remotivelabs/cli/cloud/auth_tokens.py +149 -0
  29. remotivelabs/cli/cloud/brokers.py +109 -0
  30. remotivelabs/cli/cloud/configs.py +109 -0
  31. remotivelabs/cli/cloud/licenses/__init__.py +0 -0
  32. remotivelabs/cli/cloud/licenses/cmd.py +14 -0
  33. remotivelabs/cli/cloud/organisations.py +112 -0
  34. remotivelabs/cli/cloud/projects.py +44 -0
  35. remotivelabs/cli/cloud/recordings.py +580 -0
  36. remotivelabs/cli/cloud/recordings_playback.py +274 -0
  37. remotivelabs/cli/cloud/resumable_upload.py +87 -0
  38. remotivelabs/cli/cloud/sample_recordings.py +25 -0
  39. remotivelabs/cli/cloud/service_account_tokens.py +62 -0
  40. remotivelabs/cli/cloud/service_accounts.py +72 -0
  41. remotivelabs/cli/cloud/storage/__init__.py +5 -0
  42. remotivelabs/cli/cloud/storage/cmd.py +76 -0
  43. remotivelabs/cli/cloud/storage/copy.py +86 -0
  44. remotivelabs/cli/cloud/storage/uri_or_path.py +45 -0
  45. remotivelabs/cli/cloud/uri.py +113 -0
  46. remotivelabs/cli/connect/__init__.py +0 -0
  47. remotivelabs/cli/connect/connect.py +118 -0
  48. remotivelabs/cli/connect/protopie/protopie.py +185 -0
  49. remotivelabs/cli/py.typed +0 -0
  50. remotivelabs/cli/remotive.py +123 -0
  51. remotivelabs/cli/settings/__init__.py +20 -0
  52. remotivelabs/cli/settings/config_file.py +113 -0
  53. remotivelabs/cli/settings/core.py +333 -0
  54. remotivelabs/cli/settings/migration/__init__.py +0 -0
  55. remotivelabs/cli/settings/migration/migrate_all_token_files.py +80 -0
  56. remotivelabs/cli/settings/migration/migrate_config_file.py +64 -0
  57. remotivelabs/cli/settings/migration/migrate_legacy_dirs.py +50 -0
  58. remotivelabs/cli/settings/migration/migrate_token_file.py +52 -0
  59. remotivelabs/cli/settings/migration/migration_tools.py +38 -0
  60. remotivelabs/cli/settings/state_file.py +67 -0
  61. remotivelabs/cli/settings/token_file.py +128 -0
  62. remotivelabs/cli/tools/__init__.py +0 -0
  63. remotivelabs/cli/tools/can/__init__.py +0 -0
  64. remotivelabs/cli/tools/can/can.py +78 -0
  65. remotivelabs/cli/tools/tools.py +9 -0
  66. remotivelabs/cli/topology/__init__.py +28 -0
  67. remotivelabs/cli/topology/all.py +322 -0
  68. remotivelabs/cli/topology/cli/__init__.py +3 -0
  69. remotivelabs/cli/topology/cli/run_in_docker.py +58 -0
  70. remotivelabs/cli/topology/cli/topology_cli.py +16 -0
  71. remotivelabs/cli/topology/cmd.py +130 -0
  72. remotivelabs/cli/topology/start_trial.py +134 -0
  73. remotivelabs/cli/typer/__init__.py +0 -0
  74. remotivelabs/cli/typer/typer_utils.py +27 -0
  75. remotivelabs/cli/utils/__init__.py +0 -0
  76. remotivelabs/cli/utils/console.py +99 -0
  77. remotivelabs/cli/utils/rest_helper.py +369 -0
  78. remotivelabs/cli/utils/time.py +11 -0
  79. remotivelabs/cli/utils/versions.py +120 -0
  80. remotivelabs_cli-0.5.0a1.dist-info/METADATA +51 -0
  81. remotivelabs_cli-0.5.0a1.dist-info/RECORD +84 -0
  82. remotivelabs_cli-0.5.0a1.dist-info/WHEEL +4 -0
  83. remotivelabs_cli-0.5.0a1.dist-info/entry_points.txt +3 -0
  84. remotivelabs_cli-0.5.0a1.dist-info/licenses/LICENSE +17 -0
@@ -0,0 +1,27 @@
1
+ from typing import Any
2
+
3
+ import typer
4
+ from click import Context
5
+ from typer.core import TyperGroup
6
+
7
+ from remotivelabs.cli.utils.console import print_generic_message
8
+
9
+
10
+ class OrderCommands(TyperGroup):
11
+ def list_commands(self, _ctx: Context): # type: ignore
12
+ return list(self.commands)
13
+
14
+
15
+ def create_typer(**kwargs: Any) -> typer.Typer:
16
+ """Create a Typer instance with default settings."""
17
+ return typer.Typer(cls=OrderCommands, no_args_is_help=True, invoke_without_command=True, **kwargs)
18
+
19
+
20
+ def create_typer_sorted(**kwargs: Any) -> typer.Typer:
21
+ """Create a Typer instance with default settings."""
22
+ return typer.Typer(no_args_is_help=True, invoke_without_command=True, **kwargs)
23
+
24
+
25
+ def print_padded(label: str, right_text: str, length: int = 30) -> None:
26
+ padded_label = label.ljust(length) # pad to 30 characters
27
+ print_generic_message(f"{padded_label} {right_text}")
File without changes
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ from typing import Any
7
+
8
+ import grpc
9
+ from rich.console import Console
10
+
11
+ console = Console(soft_wrap=True)
12
+ err_console = Console(stderr=True, soft_wrap=True)
13
+
14
+
15
+ def print_grpc_error(error: grpc.RpcError) -> None:
16
+ """TODO: remove me"""
17
+ if error.code() == grpc.StatusCode.UNAUTHENTICATED:
18
+ is_access_token = os.environ["ACCESS_TOKEN"]
19
+ if is_access_token is not None and is_access_token == "true":
20
+ err_console.print(f":boom: [bold red]Authentication failed[/bold red]: {error.details()}")
21
+ err_console.print("Please login again")
22
+ else:
23
+ err_console.print(":boom: [bold red]Authentication failed[/bold red]")
24
+ err_console.print("Failed to verify api-key")
25
+ else:
26
+ err_console.print(f":boom: [bold red]Unexpected error, status code[/bold red]: {error.code()}")
27
+ err_console.print(error.details())
28
+ sys.exit(1)
29
+
30
+
31
+ def print_newline() -> None:
32
+ """TODO: remove me"""
33
+ console.print("\n")
34
+
35
+
36
+ def print_url(url: str) -> None:
37
+ console.print(url, style="bold")
38
+
39
+
40
+ def print_unformatted(message: Any) -> None:
41
+ """TODO: remove me"""
42
+ console.print(message)
43
+
44
+
45
+ def print_unformatted_to_stderr(message: Any) -> None:
46
+ """TODO: remove me"""
47
+ err_console.print(message)
48
+
49
+
50
+ def print_success(message: str | None = None) -> None:
51
+ """
52
+ Print a success message to stdout
53
+
54
+ TODO: use stderr instead.
55
+ """
56
+ msg = "[bold green]Success![/bold green]"
57
+ if message:
58
+ msg += f" {message}"
59
+ console.print(msg)
60
+
61
+
62
+ def print_generic_error(message: str | None = None) -> None:
63
+ """
64
+ Print a failure message to stderr
65
+
66
+ TODO: rename to print_failure
67
+ """
68
+ msg = ":boom: [bold red]Failed[/bold red]"
69
+ if message:
70
+ msg += f": {message}"
71
+ err_console.print(msg)
72
+
73
+
74
+ def print_generic_message(message: str) -> None:
75
+ """
76
+ Print a message to the user.
77
+
78
+ TODO: rename to print_message
79
+ TODO: use stderr instead.
80
+ """
81
+ console.print(f"[bold]{message}[/bold]")
82
+
83
+
84
+ def print_hint(message: str) -> None:
85
+ """
86
+ Print a hint to stderr.
87
+
88
+ Useful when nudging the user to a suitable solution.
89
+ """
90
+ err_console.print(f":point_right: [bold]{message}[/bold]")
91
+
92
+
93
+ def print_result(result: Any, default: Any = None) -> None:
94
+ """
95
+ Print a result to stdout
96
+
97
+ TODO: Decide on how to handle output. In broker lib (to_json)?
98
+ """
99
+ console.print(json.dumps(result, indent=2, default=default))
@@ -0,0 +1,369 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import sys
9
+ from importlib.metadata import version
10
+ from pathlib import Path
11
+ from typing import Any, BinaryIO, Dict, List, Optional, Union, cast
12
+
13
+ import requests
14
+ from requests.exceptions import JSONDecodeError
15
+ from rich.progress import Progress, SpinnerColumn, TextColumn, wrap_file
16
+
17
+ from remotivelabs.cli.settings import settings
18
+ from remotivelabs.cli.utils import versions
19
+ from remotivelabs.cli.utils.console import (
20
+ print_generic_error,
21
+ print_generic_message,
22
+ print_hint,
23
+ print_unformatted,
24
+ print_unformatted_to_stderr,
25
+ )
26
+
27
+ if "REMOTIVE_CLOUD_HTTP_LOGGING" in os.environ:
28
+ logging.basicConfig()
29
+ logging.getLogger().setLevel(logging.DEBUG)
30
+ requests_log = logging.getLogger("requests.packages.urllib3")
31
+ requests_log.setLevel(logging.DEBUG)
32
+ requests_log.propagate = True
33
+
34
+
35
+ class RestHelper:
36
+ """Static Class with various helper functions for the rest API"""
37
+
38
+ __base_url = "https://cloud.remotivelabs.com"
39
+ __frontend_url = __base_url
40
+ __license_server_base_url = "https://license.cloud.remotivelabs.com"
41
+
42
+ if "REMOTIVE_CLOUD_BASE_URL" in os.environ:
43
+ __base_url = os.environ["REMOTIVE_CLOUD_BASE_URL"]
44
+ __frontend_url = os.environ["REMOTIVE_CLOUD_BASE_URL"]
45
+
46
+ if "REMOTIVE_CLOUD_FRONTEND_BASE_URL" in os.environ:
47
+ __frontend_url = os.environ["REMOTIVE_CLOUD_FRONTEND_BASE_URL"]
48
+
49
+ if "cloud-dev" in __base_url:
50
+ __license_server_base_url = "https://license.cloud-dev.remotivelabs.com"
51
+
52
+ __headers: Dict[str, str] = {"User-Agent": f"remotivelabs-cli/{versions.cli_version()} ({versions.platform_info()})"}
53
+ __org: str = ""
54
+
55
+ __token: str = ""
56
+
57
+ def _cli_version(self) -> str:
58
+ return ""
59
+
60
+ @staticmethod
61
+ def get_cli_version() -> str:
62
+ return version("remotivelabs-cli")
63
+
64
+ @staticmethod
65
+ def get_base_url() -> str:
66
+ return RestHelper.__base_url
67
+
68
+ @staticmethod
69
+ def get_base_frontend_url() -> str:
70
+ return RestHelper.__frontend_url
71
+
72
+ @staticmethod
73
+ def get_license_server_base_url() -> str:
74
+ return RestHelper.__license_server_base_url
75
+
76
+ @staticmethod
77
+ def get_headers() -> Dict[str, str]:
78
+ return RestHelper.__headers
79
+
80
+ @staticmethod
81
+ def get_org() -> str:
82
+ return RestHelper.__org
83
+
84
+ @staticmethod
85
+ def get_token() -> str:
86
+ return RestHelper.__token
87
+
88
+ @staticmethod
89
+ def ensure_auth_token(quiet: bool = False, access_token: Optional[str] = None) -> None:
90
+ """
91
+ TODO: remove setting org, as we already set the default organization as env in remotive.py?
92
+ TODO: don't sys.exit, raise error instead
93
+ """
94
+
95
+ if "REMOTIVE_CLOUD_ORGANIZATION" not in os.environ:
96
+ active_account = settings.get_active_account()
97
+ if active_account:
98
+ org = active_account.default_organization
99
+ if org:
100
+ os.environ["REMOTIVE_CLOUD_ORGANIZATION"] = org
101
+
102
+ token = access_token
103
+ if not token:
104
+ token = settings.get_active_token()
105
+
106
+ if not token:
107
+ if quiet:
108
+ return
109
+ print_hint("you are not logged in, please login using [green]remotive cloud auth login[/green]")
110
+ sys.exit(1)
111
+
112
+ RestHelper.__headers["Authorization"] = f"Bearer {token.strip()}"
113
+
114
+ @staticmethod
115
+ def handle_get( # noqa: PLR0913
116
+ url: str,
117
+ params: Any = None,
118
+ return_response: bool = False,
119
+ allow_status_codes: List[int] | None = None,
120
+ progress_label: str = "Fetching...",
121
+ use_progress_indicator: bool = True,
122
+ allow_redirects: bool = False,
123
+ timeout: int = 60,
124
+ access_token: Optional[str] = None,
125
+ skip_access_token: bool = False,
126
+ ) -> requests.Response:
127
+ # Returns a Response object if succesfull otherwise None
128
+ if params is None:
129
+ params = {}
130
+ if not skip_access_token:
131
+ RestHelper.ensure_auth_token(access_token=access_token)
132
+ if use_progress_indicator:
133
+ with RestHelper.use_progress(progress_label):
134
+ r = requests.get(
135
+ f"{RestHelper.__base_url}{url}",
136
+ headers=RestHelper.__headers,
137
+ params=params,
138
+ timeout=timeout,
139
+ allow_redirects=allow_redirects,
140
+ )
141
+ else:
142
+ r = requests.get(
143
+ f"{RestHelper.__base_url}{url}",
144
+ headers=RestHelper.__headers,
145
+ params=params,
146
+ timeout=timeout,
147
+ allow_redirects=allow_redirects,
148
+ )
149
+
150
+ if return_response:
151
+ RestHelper.check_api_result(r, allow_status_codes)
152
+ return r
153
+ RestHelper.print_api_result(r)
154
+ sys.exit(0)
155
+
156
+ @staticmethod
157
+ def has_access(url: str, params: Any = {}, access_token: Optional[str] = None) -> bool:
158
+ RestHelper.ensure_auth_token(quiet=True, access_token=access_token)
159
+ r = requests.get(f"{RestHelper.__base_url}{url}", headers=RestHelper.__headers, params=params, timeout=60)
160
+ if 200 <= r.status_code <= 299:
161
+ return True
162
+ return False
163
+
164
+ @staticmethod
165
+ def check_api_result(response: requests.Response, allow_status_codes: List[int] | None = None) -> None:
166
+ """
167
+ TODO: don't sys.exit, raise error instead
168
+ """
169
+ if response.status_code == 426: # CLI upgrade
170
+ print_hint(response.text)
171
+ sys.exit(1)
172
+ if response.status_code > 299:
173
+ if allow_status_codes is not None and response.status_code in allow_status_codes:
174
+ return
175
+ print_generic_error(f"Got status code: {response.status_code}")
176
+ if response.status_code == 401:
177
+ print_generic_message("Your token is not valid or has expired, please login again or activate another account")
178
+ else:
179
+ print_unformatted_to_stderr(response.text)
180
+ sys.exit(1)
181
+
182
+ @staticmethod
183
+ def print_api_result(response: requests.Response) -> None:
184
+ """
185
+ TODO: don't sys.exit, raise error instead
186
+ TODO: dont print from here, return and let caller print instead
187
+ """
188
+ if response.status_code == 426: # CLI upgrade
189
+ print_hint(response.text)
190
+ sys.exit(1)
191
+
192
+ if response.status_code >= 200 and response.status_code < 300:
193
+ if len(response.content) >= 2:
194
+ try:
195
+ print_unformatted(json.dumps(response.json()))
196
+ except JSONDecodeError:
197
+ print_generic_error("Json parse error: Please try again and report if the error persists")
198
+ sys.exit(0)
199
+ else:
200
+ print_generic_error(f"Got status code: {response.status_code}")
201
+ if response.status_code == 401:
202
+ print_generic_message("Your token is not valid or has expired, please login again or activate another account")
203
+ else:
204
+ print_unformatted_to_stderr(response.text)
205
+ sys.exit(1)
206
+
207
+ @staticmethod
208
+ def handle_patch( # noqa: PLR0913
209
+ url: str,
210
+ params: Any = {},
211
+ quiet: bool = False,
212
+ progress_label: str = "Deleting...",
213
+ access_token: Optional[str] = None,
214
+ allow_status_codes: Optional[List[int]] = None,
215
+ ) -> requests.Response:
216
+ if allow_status_codes is None:
217
+ allow_status_codes = []
218
+ RestHelper.ensure_auth_token(access_token=access_token)
219
+ with RestHelper.use_progress(progress_label):
220
+ r = requests.patch(f"{RestHelper.__base_url}{url}", headers=RestHelper.__headers, params=params, timeout=60)
221
+ if r.status_code in (200, 204):
222
+ if not quiet:
223
+ RestHelper.print_api_result(r)
224
+ elif r.status_code not in allow_status_codes:
225
+ RestHelper.print_api_result(r)
226
+ return r
227
+
228
+ @staticmethod
229
+ def handle_delete( # noqa: PLR0913
230
+ url: str,
231
+ params: Any = {},
232
+ quiet: bool = False,
233
+ progress_label: str = "Deleting...",
234
+ access_token: Optional[str] = None,
235
+ allow_status_codes: Optional[List[int]] = None,
236
+ ) -> requests.Response:
237
+ if allow_status_codes is None:
238
+ allow_status_codes = []
239
+ RestHelper.ensure_auth_token(access_token=access_token)
240
+ with RestHelper.use_progress(progress_label):
241
+ r = requests.delete(f"{RestHelper.__base_url}{url}", headers=RestHelper.__headers, params=params, timeout=60)
242
+ if r.status_code in (200, 204):
243
+ if not quiet:
244
+ RestHelper.print_api_result(r)
245
+ elif r.status_code not in allow_status_codes:
246
+ RestHelper.print_api_result(r)
247
+ return r
248
+
249
+ @staticmethod
250
+ def handle_post( # noqa: PLR0913
251
+ url: str,
252
+ body: Any = None,
253
+ params: Any = {},
254
+ progress_label: str = "Processing...",
255
+ return_response: bool = False,
256
+ access_token: Optional[str] = None,
257
+ ) -> requests.Response:
258
+ # Returns a Response object if succesfull otherwise, None
259
+
260
+ RestHelper.ensure_auth_token(access_token=access_token)
261
+ RestHelper.__headers["content-type"] = "application/json"
262
+
263
+ with RestHelper.use_progress(progress_label):
264
+ r = requests.post(f"{RestHelper.__base_url}{url}", headers=RestHelper.__headers, params=params, data=body, timeout=60)
265
+
266
+ if return_response:
267
+ RestHelper.check_api_result(r)
268
+ return r
269
+
270
+ RestHelper.print_api_result(r)
271
+ sys.exit(0)
272
+
273
+ @staticmethod
274
+ def handle_put(url: str, body: Any = None, params: Any = {}, return_response: bool = False) -> requests.Response | None:
275
+ # Returns a Response object if succesfull otherwise, None
276
+ RestHelper.ensure_auth_token()
277
+ RestHelper.__headers["content-type"] = "application/json"
278
+ r = requests.put(f"{RestHelper.__base_url}{url}", headers=RestHelper.__headers, params=params, data=body, timeout=60)
279
+
280
+ if return_response:
281
+ RestHelper.check_api_result(r)
282
+ return r
283
+ RestHelper.print_api_result(r)
284
+ return None
285
+
286
+ @staticmethod
287
+ def upload_file(
288
+ path: Union[str, Path],
289
+ url: str,
290
+ upload_headers: Dict[str, str] | None = None,
291
+ return_response: bool = False,
292
+ progress_label: str = "Uploading...",
293
+ ) -> requests.Response | None:
294
+ # Returns a Response object if succesfull otherwise, None
295
+ RestHelper.ensure_auth_token()
296
+ if upload_headers is not None:
297
+ RestHelper.__headers.update(upload_headers)
298
+ with open(path, "rb") as file:
299
+ with wrap_file(file, os.stat(path).st_size, description=progress_label) as f:
300
+ r = requests.post(
301
+ f"{RestHelper.__base_url}{url}", files={os.path.basename(path): f}, headers=RestHelper.__headers, timeout=60
302
+ )
303
+ if return_response:
304
+ RestHelper.check_api_result(r)
305
+ return r
306
+ RestHelper.print_api_result(r)
307
+ return None
308
+
309
+ @staticmethod
310
+ def upload_file_with_signed_url(
311
+ path: Union[str, Path],
312
+ url: str,
313
+ upload_headers: Dict[str, str],
314
+ return_response: bool = False,
315
+ progress_label: str = "Uploading...",
316
+ ) -> requests.Response | None:
317
+ # Returns a Response object if succesfull otherwise, None
318
+ with open(path, "rb") as file:
319
+ with wrap_file(file, os.stat(path).st_size, description=progress_label, transient=False) as f:
320
+ r = requests.put(url, data=f, headers=upload_headers, timeout=60)
321
+ if return_response:
322
+ RestHelper.check_api_result(r)
323
+ return r
324
+ RestHelper.print_api_result(r)
325
+ return None
326
+
327
+ @staticmethod
328
+ def use_progress(label: str, transient: bool = True) -> Progress:
329
+ p = Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=transient)
330
+ p.add_task(label, total=1)
331
+ return p
332
+
333
+ @staticmethod
334
+ def download_file(save_file_name: Path, url: str) -> None:
335
+ # Next download the actual file
336
+ download_resp = requests.get(url=url, stream=True, timeout=60)
337
+ if download_resp.status_code == 200:
338
+ content_length = int(download_resp.headers["Content-Length"])
339
+ with open(save_file_name, "wb") as out_file:
340
+ stream = cast(BinaryIO, download_resp.raw) # we know this is a binary stream, as stream=True is set in the request
341
+ with wrap_file(
342
+ stream,
343
+ content_length,
344
+ refresh_per_second=100,
345
+ description=f"Downloading to {save_file_name}",
346
+ ) as stream_with_progress:
347
+ shutil.copyfileobj(stream_with_progress, out_file)
348
+ else:
349
+ RestHelper.check_api_result(download_resp)
350
+
351
+ @staticmethod
352
+ def request_license(email: str, machine_id: Dict[str, Any]) -> str:
353
+ # Lets keep the email here so we have the same interface for both authenticated
354
+ # and not authenticated license requests.
355
+ # email will be validated in the license server to make sure it matches with the user of the
356
+ # access token so not any email is sent here
357
+ RestHelper.ensure_auth_token()
358
+ payload = {"id": email, "machine_id": machine_id}
359
+ b64_encoded_bytes = base64.encodebytes(json.dumps(payload).encode())
360
+ license_jsonb64 = {"licensejsonb64": b64_encoded_bytes.decode("utf-8")}
361
+ RestHelper.__headers["content-type"] = "application/json"
362
+ r = requests.post(
363
+ url=f"{RestHelper.__license_server_base_url}/api/license/request",
364
+ headers=RestHelper.__headers,
365
+ data=json.dumps(license_jsonb64),
366
+ timeout=60,
367
+ )
368
+ RestHelper.check_api_result(r)
369
+ return str(r.json()["license_data"])
@@ -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,120 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ import json
5
+ import os
6
+ import platform
7
+ import urllib.request
8
+ from importlib import metadata as importlib_metadata
9
+ from importlib.metadata import version as python_project_version
10
+
11
+ from packaging.version import InvalidVersion, Version
12
+
13
+ from remotivelabs.cli.settings import Settings
14
+ from remotivelabs.cli.utils.console import print_hint
15
+
16
+
17
+ def cli_version() -> str:
18
+ return python_project_version("remotivelabs-cli")
19
+
20
+
21
+ def python_version() -> str:
22
+ return platform.python_version()
23
+
24
+
25
+ def host_os() -> str:
26
+ return platform.system().lower() # 'linux', 'darwin', 'windows'
27
+
28
+
29
+ def host_env() -> str:
30
+ return "docker" if os.environ.get("RUNS_IN_DOCKER") else "native"
31
+
32
+
33
+ def platform_info() -> str:
34
+ return f"python {python_version()}; {host_os()}; {host_env()}"
35
+
36
+
37
+ def _pypi_latest(
38
+ project: str, *, include_prereleases: bool, timeout: float = 2.5, user_agent: str | None = None
39
+ ) -> tuple[str | None, str | None]:
40
+ """Return (latest_version, project_url) from PyPI, skipping yanked files."""
41
+ url = f"https://pypi.org/pypi/{project}/json"
42
+ headers = {"Accept": "application/json"}
43
+ if user_agent:
44
+ headers["User-Agent"] = user_agent
45
+ req = urllib.request.Request(url, headers=headers)
46
+
47
+ try:
48
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
49
+ data = json.load(resp)
50
+ except Exception:
51
+ return None, None # network/404/etc.
52
+
53
+ releases = data.get("releases") or {}
54
+ candidates: list[Version] = []
55
+ for s, files in releases.items():
56
+ try:
57
+ v = Version(s)
58
+ except InvalidVersion:
59
+ continue
60
+ the_files = files or []
61
+ if any(f.get("yanked", False) for f in the_files):
62
+ continue
63
+ if (v.is_prerelease or v.is_devrelease) and not include_prereleases:
64
+ continue
65
+ candidates.append(v)
66
+
67
+ if not candidates:
68
+ return None, None
69
+
70
+ latest = str(max(candidates))
71
+ info = data.get("info") or {}
72
+ proj_url = info.get("project_url") or info.get("package_url") or f"https://pypi.org/project/{project}/"
73
+ return latest, proj_url
74
+
75
+
76
+ def _installed_version(distribution_name: str, fallback: str | None = None) -> str | None:
77
+ try:
78
+ return importlib_metadata.version(distribution_name)
79
+ except importlib_metadata.PackageNotFoundError:
80
+ return fallback
81
+
82
+
83
+ def check_for_update(settings: Settings) -> None:
84
+ # Make it possible to disable update check, i.e in CI
85
+ if os.environ.get("PYTHON_DISABLE_UPDATE_CHECK"):
86
+ return
87
+
88
+ # Check if we are allowed to perform an update check
89
+ if not settings.should_perform_update_check():
90
+ return
91
+
92
+ # Determine current version
93
+ project = "remotivelabs-cli"
94
+ cur = cli_version() or _installed_version(project)
95
+ if not cur:
96
+ return # unknown version → skip silently
97
+
98
+ # We end up here if last_update_check_time is None or should_perform_update_check is true
99
+ include_prereleases = Version(cur).is_prerelease or Version(cur).is_devrelease
100
+
101
+ latest, proj_url = _pypi_latest(
102
+ project, include_prereleases=include_prereleases, user_agent=f"{project}/{cur} (+https://pypi.org/project/{project}/)"
103
+ )
104
+ if latest:
105
+ if Version(latest) > Version(cur):
106
+ _print_update_info(
107
+ cur,
108
+ latest,
109
+ )
110
+ settings.set_last_update_check_time(datetime.datetime.now().isoformat())
111
+
112
+
113
+ def _print_update_info(cur: str, latest: str) -> None:
114
+ instructions = (
115
+ "upgrade with: docker pull remotivelabs/remotivelabs-cli"
116
+ if os.environ.get("RUNS_IN_DOCKER")
117
+ else "upgrade with: pipx upgrade remotivelabs-cli"
118
+ )
119
+
120
+ print_hint(f"Update available: remotivelabs-cli {cur} → {latest} , ({instructions}) we always recommend to use latest version")
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: remotivelabs-cli
3
+ Version: 0.5.0a1
4
+ Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
+ Keywords: automotive,autotech,networking,CAN
6
+ Author: Remotivelabs
7
+ Author-email: Remotivelabs <support@remotivelabs.com>
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Intended Audience :: Manufacturing
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Communications
22
+ Classifier: Topic :: Internet
23
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
24
+ Requires-Dist: trogon>=0.5.0
25
+ Requires-Dist: typer==0.12.5
26
+ Requires-Dist: click<8.2.0
27
+ Requires-Dist: remotivelabs-broker~=0.11.1
28
+ Requires-Dist: rich~=13.7.0
29
+ Requires-Dist: pyjwt~=2.6
30
+ Requires-Dist: zeroconf~=0.127.0
31
+ Requires-Dist: websocket-client~=1.6
32
+ Requires-Dist: plotext~=5.2
33
+ Requires-Dist: python-socketio>=4.6.1
34
+ Requires-Dist: python-can>=4.3.1
35
+ Requires-Dist: grpc-stubs>=1.53.0.5
36
+ Requires-Dist: mypy-protobuf>=3.0.0
37
+ Requires-Dist: types-requests~=2.32.0.20240622
38
+ Requires-Dist: pydantic~=2.12.4
39
+ Requires-Dist: email-validator~=2.2.0
40
+ Requires-Dist: requests~=2.32.4
41
+ Requires-Dist: semver>=3.0.4
42
+ Requires-Python: >=3.10, <4.0
43
+ Description-Content-Type: text/markdown
44
+
45
+ # RemotiveLabs - CLI
46
+ [![PyPI - Version](https://img.shields.io/pypi/v/remotivelabs-cli.svg)](https://pypi.org/project/remotivelabs-cli)
47
+
48
+ Use this CLI with our cloud and broker as a compliment to code and web tools.
49
+
50
+ Read more at https://docs.remotivelabs.com/docs/remotive-cli
51
+