remotivelabs-cli 0.0.31__py3-none-any.whl → 0.0.33__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.
cli/broker/lib/broker.py CHANGED
@@ -18,6 +18,7 @@ import remotivelabs.broker.generated.sync.traffic_api_pb2 as traffic_api
18
18
  import remotivelabs.broker.sync as br
19
19
  import remotivelabs.broker.sync.helper as br_helper
20
20
  import typer
21
+ from google.protobuf.json_format import MessageToDict
21
22
  from rich.console import Console
22
23
 
23
24
  from cli import settings
@@ -360,7 +361,7 @@ class Broker:
360
361
  namespaces.append(network_info.namespace.name)
361
362
  return namespaces
362
363
 
363
- def list_signal_names(self) -> List[Dict[str, Any]]:
364
+ def list_signal_names(self, prefix: Union[str, None], suffix: Union[str, None]) -> List[Dict[str, Any]]:
364
365
  # Lists available signals
365
366
  configuration = self.system_stub.GetConfiguration(br.common_pb2.Empty())
366
367
 
@@ -368,30 +369,37 @@ class Broker:
368
369
  for network_info in configuration.networkInfo:
369
370
  res = self.system_stub.ListSignals(network_info.namespace)
370
371
  for finfo in res.frame:
371
- # f: br.common_pb2.FrameInfo = finfo
372
- receivers = []
373
- for sinfo in finfo.childInfo:
374
- rec = list(map(lambda r: r, sinfo.metaData.receiver))
375
- receivers.extend(rec)
376
- signal_names.append(
377
- {
378
- "signal": sinfo.id.name,
379
- "namespace": network_info.namespace.name,
380
- "receivers": rec,
381
- "min": sinfo.metaData.min,
382
- "max": sinfo.metaData.max,
383
- }
372
+ if (prefix is None or finfo.signalInfo.id.name.startswith(prefix)) and (
373
+ suffix is None or finfo.signalInfo.id.name.endswith(suffix)
374
+ ):
375
+ metadata_dict = MessageToDict(
376
+ finfo.signalInfo.metaData,
377
+ including_default_value_fields=True,
378
+ preserving_proto_field_name=True,
384
379
  )
385
-
386
- signal_names.append(
387
- {
380
+ sig_dict = {
388
381
  "signal": finfo.signalInfo.id.name,
389
382
  "namespace": network_info.namespace.name,
390
- "senders": list(map(lambda s: s, finfo.signalInfo.metaData.sender)),
391
- "receivers": list(set(receivers)),
392
- "cycletime": finfo.signalInfo.metaData.cycleTime,
393
383
  }
394
- )
384
+ signal_names.append({**sig_dict, **metadata_dict})
385
+
386
+ for sinfo in finfo.childInfo:
387
+ # For signals we can simply skip if prefix and suffix exists does not match
388
+ if (prefix is not None and not sinfo.id.name.startswith(prefix)) or (
389
+ suffix is not None and not sinfo.id.name.endswith(suffix)
390
+ ):
391
+ continue
392
+
393
+ metadata_dict = MessageToDict(
394
+ sinfo.metaData,
395
+ including_default_value_fields=True,
396
+ preserving_proto_field_name=True,
397
+ )
398
+ sig_dict = {
399
+ "signal": sinfo.id.name,
400
+ "namespace": network_info.namespace.name,
401
+ }
402
+ signal_names.append({**sig_dict, **metadata_dict})
395
403
 
396
404
  return signal_names
397
405
 
@@ -433,7 +441,7 @@ class Broker:
433
441
  def find_subscribed_signal(available_signal: List[Dict[str, str]]) -> List[str]:
434
442
  return list(filter(lambda s: available_signal["signal"] == s, subscribed_signals)) # type: ignore
435
443
 
436
- existing_signals = self.list_signal_names()
444
+ existing_signals = self.list_signal_names(prefix=None, suffix=None)
437
445
  existing_ns = set(map(lambda s: s["namespace"], existing_signals))
438
446
  ns_not_matching = []
439
447
  for ns in subscribed_namespaces:
cli/broker/signals.py CHANGED
@@ -5,7 +5,7 @@ import numbers
5
5
  import os
6
6
  import signal as os_signal
7
7
  from pathlib import Path
8
- from typing import Any, Dict, Iterable, List, TypedDict
8
+ from typing import Any, Dict, Iterable, List, TypedDict, Union
9
9
 
10
10
  import grpc
11
11
  import plotext as plt # type: ignore
@@ -30,23 +30,21 @@ class Signals(TypedDict):
30
30
  signal_values: Dict[Any, Any] = {}
31
31
 
32
32
 
33
- @app.command(name="list", help="List frame and signal metadata on broker")
33
+ @app.command(name="list")
34
34
  def list_signals(
35
35
  url: str = typer.Option(..., help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
36
36
  api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
37
+ name_starts_with: Union[str, None] = typer.Option(None, help="Signal name prefix to include"),
38
+ name_ends_with: Union[str, None] = typer.Option(None, help="Signal name suffix to include"),
37
39
  ) -> None:
38
- signal_names(url, api_key)
39
-
40
+ """
41
+ List signal metadata on a broker
40
42
 
41
- @app.command(help="List signals names on broker", deprecated=True)
42
- def signal_names(
43
- url: str = typer.Option(..., help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
44
- api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
45
- ) -> None:
43
+ Filter are inclusive so --name-starts-with and --name-ends-with will include name that matches both
44
+ """
46
45
  try:
47
46
  broker = Broker(url, api_key)
48
- # print("Listing available signals")
49
- available_signals = broker.list_signal_names()
47
+ available_signals = broker.list_signal_names(prefix=name_starts_with, suffix=name_ends_with)
50
48
  print(json.dumps(available_signals))
51
49
  except grpc.RpcError as rpc_error:
52
50
  ErrorPrinter.print_grpc_error(rpc_error)
@@ -0,0 +1,3 @@
1
+ from cli.cloud.auth.cmd import app
2
+
3
+ __all__ = ["app"]
cli/cloud/auth/cmd.py ADDED
@@ -0,0 +1,46 @@
1
+ import typer
2
+
3
+ from cli import settings
4
+ from cli.cloud.auth.login import login as do_login
5
+ from cli.cloud.rest_helper import RestHelper as Rest
6
+
7
+ from .. import auth_tokens
8
+
9
+ HELP = """
10
+ Manage how you authenticate with our cloud platform
11
+ """
12
+ app = typer.Typer(help=HELP)
13
+ app.add_typer(auth_tokens.app, name="tokens", help="Manage users personal access tokens")
14
+
15
+
16
+ @app.command(name="login")
17
+ def login() -> None:
18
+ """
19
+ Login to the cli using browser
20
+
21
+ This will be used as the current access token in all subsequent requests. This would
22
+ be the same as activating a personal access key or service-account access key.
23
+ """
24
+ do_login()
25
+
26
+
27
+ @app.command()
28
+ def whoami() -> None:
29
+ """
30
+ Validates authentication and fetches your user information
31
+ """
32
+ Rest.handle_get("/api/whoami")
33
+
34
+
35
+ @app.command()
36
+ def print_access_token() -> None:
37
+ """
38
+ Print current active access token
39
+ """
40
+ print(settings.read_secret_token())
41
+
42
+
43
+ @app.command(help="Clear access token")
44
+ def logout() -> None:
45
+ settings.clear_secret_token()
46
+ print("Access token removed")
@@ -4,23 +4,13 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
4
4
  from threading import Thread
5
5
  from typing import Any
6
6
 
7
- import typer
8
7
  from typing_extensions import override
9
8
 
10
9
  from cli import settings
11
-
12
- from . import auth_tokens
13
- from .rest_helper import RestHelper as Rest
14
-
15
- HELP = """
16
- Manage how you authenticate with our cloud platform
17
- """
10
+ from cli.cloud.rest_helper import RestHelper as Rest
18
11
 
19
12
  httpd: HTTPServer
20
13
 
21
- app = typer.Typer(help=HELP)
22
- app.add_typer(auth_tokens.app, name="tokens", help="Manage users personal access tokens")
23
-
24
14
 
25
15
  class S(BaseHTTPRequestHandler):
26
16
  def _set_response(self) -> None:
@@ -55,46 +45,10 @@ def start_local_webserver(server_class: type = HTTPServer, handler_class: type =
55
45
  httpd = server_class(server_address, handler_class)
56
46
 
57
47
 
58
- #
59
- # CLI commands go here
60
- #
61
-
62
-
63
- @app.command(name="login")
64
48
  def login() -> None:
65
49
  """
66
- Login to the cli using browser
67
-
68
- This will be used as the current access token in all subsequent requests. This would
69
- be the same as activating a personal access key or service-account access key.
50
+ Initiate login using browser
70
51
  """
71
52
  start_local_webserver()
72
- webbrowser.open(
73
- f"{Rest.get_base_url()}/login?redirectUrl=http://localhost:{httpd.server_address[1]}",
74
- new=1,
75
- autoraise=True,
76
- )
77
-
53
+ webbrowser.open_new_tab(f"{Rest.get_base_url()}/login?redirectUrl=http://localhost:{httpd.server_address[1]}")
78
54
  httpd.serve_forever()
79
-
80
-
81
- @app.command()
82
- def whoami() -> None:
83
- """
84
- Validates authentication and fetches your user information
85
- """
86
- Rest.handle_get("/api/whoami")
87
-
88
-
89
- @app.command()
90
- def print_access_token() -> None:
91
- """
92
- Print current active access token
93
- """
94
- print(settings.read_secret_token())
95
-
96
-
97
- @app.command(help="Clear access token")
98
- def logout() -> None:
99
- settings.clear_secret_token()
100
- print("Access token removed")
cli/cloud/cloud_cli.py CHANGED
@@ -1,9 +1,8 @@
1
1
  import typer
2
2
 
3
+ from cli.cloud import auth, brokers, configs, organisations, projects, recordings, sample_recordings, service_accounts, storage
3
4
  from cli.cloud.rest_helper import RestHelper
4
5
 
5
- from . import auth, brokers, configs, filestorage, organisations, projects, recordings, sample_recordings, service_accounts
6
-
7
6
  app = typer.Typer()
8
7
 
9
8
 
@@ -21,7 +20,7 @@ app.add_typer(auth.app, name="auth")
21
20
  app.add_typer(brokers.app, name="brokers", help="Manage cloud broker lifecycle")
22
21
  app.add_typer(recordings.app, name="recordings", help="Manage recordings")
23
22
  app.add_typer(configs.app, name="signal-databases", help="Manage signal databases")
24
- app.add_typer(filestorage.app, name="storage")
23
+ app.add_typer(storage.app, name="storage")
25
24
  app.add_typer(service_accounts.app, name="service-accounts", help="Manage project service account keys")
26
25
  app.add_typer(sample_recordings.app, name="samples", help="Manage sample recordings")
27
26
 
cli/cloud/configs.py CHANGED
@@ -48,10 +48,14 @@ def upload(
48
48
  """
49
49
  Uploads signal database to project
50
50
  """
51
- res = Rest.handle_put(url=f"/api/project/{project}/files/config/{os.path.basename(path)}/uploadfile", return_response=True)
52
- if res is not None:
51
+ res_text = Rest.handle_put(url=f"/api/project/{project}/files/config/{os.path.basename(path)}/uploadfile", return_response=True)
52
+ if res_text is not None:
53
+ res_json = res_text.json()
53
54
  Rest.upload_file_with_signed_url(
54
- path=path, url=res.text, upload_headers={"Content-Type": "application/octet-stream"}, progress_label=f"Uploading {path}..."
55
+ path=path,
56
+ url=res_json["url"],
57
+ upload_headers={"Content-Type": "application/octet-stream"},
58
+ progress_label=f"Uploading {path}...",
55
59
  )
56
60
 
57
61
 
cli/cloud/recordings.py CHANGED
@@ -9,7 +9,7 @@ import tempfile
9
9
  import time
10
10
  import urllib.parse
11
11
  from pathlib import Path
12
- from typing import Any, Dict, List, Tuple, Union
12
+ from typing import Any, Dict, List, Optional, Tuple, Union
13
13
  from urllib.parse import quote
14
14
 
15
15
  import grpc
@@ -119,12 +119,10 @@ def __check_rcs_path(path: str) -> str:
119
119
 
120
120
 
121
121
  def do_start(name: str, project: str, api_key: str, return_response: bool = False) -> requests.Response:
122
- if api_key == "":
123
- body = {"size": "S"}
124
- else:
125
- body = {"size": "S", "apiKey": api_key}
122
+ body = {"size": "S"}
123
+ if not api_key:
124
+ body["apiKey"] = api_key
126
125
 
127
- name = name if name is not None else "personal"
128
126
  return Rest.handle_post(
129
127
  f"/api/project/{project}/brokers/{name}",
130
128
  body=json.dumps(body),
@@ -136,7 +134,7 @@ def do_start(name: str, project: str, api_key: str, return_response: bool = Fals
136
134
  @app.command(help="Prepares all recording files and transformations to be available for playback")
137
135
  def mount( # noqa: C901
138
136
  recording_session: str = typer.Argument(..., help="Recording session id", envvar="REMOTIVE_CLOUD_RECORDING_SESSION"),
139
- broker: str = typer.Option(None, help="Broker to use"),
137
+ broker: Optional[str] = typer.Option(None, help="Broker to use"),
140
138
  ensure_broker_started: bool = typer.Option(default=False, help="Ensure broker exists, start otherwise"),
141
139
  transformation_name: str = typer.Option("default", help="Specify a custom signal transformation to use"),
142
140
  project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
@@ -153,7 +151,7 @@ def mount( # noqa: C901
153
151
  broker = r.json()["shortName"]
154
152
 
155
153
  elif r.status_code == 404:
156
- r = do_start(None, project, "", return_response=True)
154
+ r = do_start("personal", project, "", return_response=True)
157
155
  if r.status_code != 200:
158
156
  print(r.text)
159
157
  sys.exit(0)
@@ -208,7 +206,7 @@ def download_recording_file(
208
206
  )
209
207
  if get_signed_url_resp.status_code == 200:
210
208
  # Next download the actual file
211
- Rest.download_file(recording_file_name, get_signed_url_resp.json()["downloadUrl"])
209
+ Rest.download_file(Path(recording_file_name), get_signed_url_resp.json()["downloadUrl"])
212
210
  print(f"Downloaded {recording_file_name}")
213
211
  else:
214
212
  print(get_signed_url_resp)
cli/cloud/rest_helper.py CHANGED
@@ -250,7 +250,7 @@ class RestHelper:
250
250
  return p
251
251
 
252
252
  @staticmethod
253
- def download_file(save_file_name: str, url: str) -> None:
253
+ def download_file(save_file_name: Path, url: str) -> None:
254
254
  # Next download the actual file
255
255
  download_resp = requests.get(url=url, stream=True, timeout=60)
256
256
  if download_resp.status_code == 200:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import os
4
4
  import sys
5
+ from pathlib import Path
5
6
  from typing import Dict
6
7
 
7
8
  import requests
@@ -66,7 +67,7 @@ def with_resumable_upload_signed_url(signed_url: str, source_file_name: str, con
66
67
  print(f"File {source_file_name} uploaded successfully.")
67
68
 
68
69
 
69
- def upload_signed_url(signed_url: str, source_file_name: str, headers: Dict[str, str]) -> None:
70
+ def upload_signed_url(signed_url: str, source_file_name: Path, headers: Dict[str, str]) -> None:
70
71
  """
71
72
  Upload file to file storage with signed url and resumable upload.
72
73
  Resumable upload will only work with the same URL and not if a new signed URL is requested with the
@@ -76,7 +77,6 @@ def upload_signed_url(signed_url: str, source_file_name: str, headers: Dict[str,
76
77
  :param source_file_name:
77
78
  :return:
78
79
  """
79
-
80
80
  with open(source_file_name, "rb") as file:
81
81
  with wrap_file(file, os.stat(source_file_name).st_size, description=f"Uploading {source_file_name}...") as f:
82
82
  response = requests.put(signed_url, headers=headers, timeout=60, data=f)
@@ -0,0 +1,5 @@
1
+ from cli.cloud.storage.cmd import app
2
+ from cli.cloud.storage.uri import URI
3
+ from cli.cloud.storage.uri_or_path import UriOrPath
4
+
5
+ __all__ = ["URI", "UriOrPath", "app"]
@@ -0,0 +1,76 @@
1
+ import sys
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+
6
+ from cli.cloud.rest_helper import RestHelper as Rest
7
+ from cli.cloud.storage.copy import copy
8
+ from cli.cloud.storage.uri import URI, InvalidURIError, JoinURIError
9
+ from cli.cloud.storage.uri_or_path import UriOrPath
10
+ from cli.cloud.storage.uri_or_path import uri as uri_parser
11
+ from cli.errors import ErrorPrinter
12
+
13
+ HELP = """
14
+ Manage files ([yellow]Beta feature not available for all customers[/yellow])
15
+
16
+ Copy file from local to remote storage and vice versa, list and delete files.
17
+
18
+ """
19
+
20
+ app = typer.Typer(rich_markup_mode="rich", help=HELP)
21
+
22
+
23
+ @app.command(name="ls")
24
+ def list_files(
25
+ project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
26
+ uri: Annotated[URI, typer.Argument(help="Remote storage path", parser=URI)] = "rcs://", # type: ignore
27
+ ) -> None:
28
+ """
29
+ Listing remote files
30
+
31
+ This will list files and directories in project top level directory
32
+ remotive cloud storage ls rcs://
33
+
34
+ This will list all files and directories matching the path
35
+ remotive cloud storage ls rcs://fileOrDirectoryPrefix
36
+
37
+ This will list all files and directories in the specified directory
38
+ remotive cloud storage ls rcs://fileOrDirectory/
39
+ """
40
+ Rest.handle_get(f"/api/project/{project}/files/storage{uri.path}")
41
+
42
+
43
+ @app.command(name="rm")
44
+ def delete_file(
45
+ project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
46
+ uri: Annotated[URI, typer.Argument(help="Remote storage path", parser=URI, show_default=False)],
47
+ ) -> None:
48
+ """
49
+ [red]Deletes[/red] a file from remote storage, this cannot be undone :fire:
50
+
51
+ [white]remotive cloud storage rm rcs://directory/filename[/white]
52
+ """
53
+ Rest.handle_delete(f"/api/project/{project}/files/storage{uri.path}")
54
+
55
+
56
+ @app.command(name="cp")
57
+ def copy_file(
58
+ project: Annotated[str, typer.Option(help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT", show_default=False)],
59
+ source: Annotated[UriOrPath, typer.Argument(help="Remote or local path to source file", parser=uri_parser, show_default=False)],
60
+ dest: Annotated[UriOrPath, typer.Argument(help="Remote or local path to destination file", parser=uri_parser, show_default=False)],
61
+ overwrite: Annotated[bool, typer.Option(help="Overwrite existing file on RCS")] = False,
62
+ ) -> None:
63
+ """
64
+ Copies a file to or from remote storage
65
+
66
+ remotive cloud storage cp rcs://dir/filename .
67
+ remotive cloud storage cp rcs://dir/filename filename
68
+
69
+ remotive cloud storage cp filename rcs://dir/
70
+ remotive cloud storage cp filename rcs://dir/filename
71
+ """
72
+ try:
73
+ return copy(project=project, source=source.value, dest=dest.value, overwrite=overwrite)
74
+ except (InvalidURIError, JoinURIError, ValueError, FileNotFoundError, FileExistsError) as e:
75
+ ErrorPrinter.print_hint(str(e))
76
+ sys.exit(1)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from cli.cloud.rest_helper import RestHelper as Rest
7
+ from cli.cloud.resumable_upload import upload_signed_url
8
+ from cli.cloud.storage.uri import URI
9
+
10
+ _RCS_STORAGE_PATH = "/api/project/{project}/files/storage{path}"
11
+
12
+
13
+ def copy(project: str, source: URI | Path, dest: URI | Path, overwrite: bool = False) -> None:
14
+ if isinstance(source, Path) and isinstance(dest, Path):
15
+ raise ValueError("Either source or destination must be an rcs:// uri")
16
+
17
+ if isinstance(source, URI) and isinstance(dest, URI):
18
+ raise ValueError("Either source or destination must be a local path")
19
+
20
+ if isinstance(source, URI) and isinstance(dest, Path):
21
+ _download(source=source, dest=dest, project=project, overwrite=overwrite)
22
+
23
+ elif isinstance(source, Path) and isinstance(dest, URI):
24
+ _upload(source=source, dest=dest, project=project, overwrite=overwrite)
25
+
26
+ else:
27
+ raise ValueError("invalid copy operation")
28
+
29
+
30
+ def _upload(source: Path, dest: URI, project: str, overwrite: bool = False) -> None:
31
+ if not source.exists():
32
+ raise FileNotFoundError(source)
33
+
34
+ files_to_upload = _list_files_for_upload(source, dest)
35
+
36
+ for file_path, target_uri in files_to_upload:
37
+ _upload_single_file(file_path, target_uri, project, overwrite)
38
+
39
+
40
+ def _list_files_for_upload(source: Path, dest: URI) -> list[tuple[Path, URI]]:
41
+ upload_pairs = []
42
+
43
+ if source.is_dir():
44
+ for file_path in source.rglob("*"):
45
+ if file_path.is_file():
46
+ relative_path = file_path.relative_to(source)
47
+ target_uri = dest / relative_path
48
+ upload_pairs.append((file_path, target_uri))
49
+ else:
50
+ target_uri = dest / source.name if dest.is_dir() else dest
51
+ upload_pairs.append((source, target_uri))
52
+
53
+ return upload_pairs
54
+
55
+
56
+ def _upload_single_file(source: Path, target_uri: URI, project: str, overwrite: bool = False) -> None:
57
+ target = _RCS_STORAGE_PATH.format(project=project, path=target_uri.path)
58
+ upload_options = {"overwrite": "always" if overwrite else "never"}
59
+ res = Rest.handle_post(target, return_response=True, body=json.dumps(upload_options))
60
+
61
+ json_res = res.json()
62
+ url = json_res["url"]
63
+ headers = json_res["headers"]
64
+ upload_signed_url(url, source, headers)
65
+
66
+ print(f"Uploaded {source} to {target_uri.path}")
67
+
68
+
69
+ def _download(source: URI, dest: Path, project: str, overwrite: bool = False) -> None:
70
+ if dest.is_dir():
71
+ if not dest.exists():
72
+ raise FileNotFoundError(f"Destination directory {dest} does not exist")
73
+ # create a target file name if destination is a dir
74
+ dest = dest / source.filename
75
+
76
+ else:
77
+ if not dest.parent.is_dir() or not dest.parent.exists():
78
+ raise FileNotFoundError(f"Destination directory {dest.parent} does not exist")
79
+
80
+ if dest.exists() and not overwrite:
81
+ raise FileExistsError(f"Destination file {dest} already exists")
82
+
83
+ target = _RCS_STORAGE_PATH.format(project=project, path=source.path) + "?download=true"
84
+ res = Rest.handle_get(target, return_response=True)
85
+
86
+ Rest.download_file(save_file_name=dest.absolute(), url=res.text)
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from os import PathLike
4
+ from pathlib import PurePosixPath
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ class InvalidURIError(Exception):
9
+ """Raised when an invalid URI is encountered"""
10
+
11
+
12
+ class JoinURIError(Exception):
13
+ """Raised when an error occurs while joining URIs"""
14
+
15
+
16
+ class URI:
17
+ """
18
+ Custom type for rcs (Remotive Cloud Storage) URIs.
19
+
20
+ The URI format follows the pattern: rcs://bucket/path/to/resource
21
+ """
22
+
23
+ scheme: str
24
+ """The URI scheme (default: 'rcs')"""
25
+
26
+ path: str
27
+ """The full path component, including leading slash"""
28
+
29
+ filename: str
30
+ """The name of the file or last path segment"""
31
+
32
+ bucket: str
33
+ """The first path segment after the leading slash"""
34
+
35
+ parent: URI
36
+ """The parent URI. If at root, returns a copy of itself."""
37
+
38
+ def __init__(self, value: str, scheme: str = "rcs"):
39
+ """
40
+ Create a new URI.
41
+
42
+ Args:
43
+ value: The URI string in format "scheme://path/to/resource"
44
+ scheme: The URI scheme (default: 'rcs')
45
+
46
+ Raises:
47
+ InvalidURIError: If the URI format is invalid
48
+ """
49
+ self._raw = value
50
+ self.scheme = scheme
51
+
52
+ parsed = urlparse(value)
53
+ if parsed.scheme != self.scheme:
54
+ raise InvalidURIError(f"Invalid URI scheme. Expected '{self.scheme}://', got '{parsed.scheme}://'")
55
+ if parsed.netloc.startswith((".", "-", "#", " ", "/", "\\")):
56
+ raise InvalidURIError(f"Invalid URI. Path cannot start with invalid characters: '{value}'")
57
+ if not parsed.netloc and parsed.path == "/":
58
+ raise InvalidURIError(f"Invalid URI: '{value}'")
59
+
60
+ self.path = f"/{parsed.netloc}{parsed.path}" if parsed.netloc else f"/{parsed.path}"
61
+
62
+ self._posix_path = PurePosixPath(self.path)
63
+ self.filename = self._posix_path.name
64
+ self.bucket = self._posix_path.parts[1] if len(self._posix_path.parts) > 1 else ""
65
+
66
+ if self._posix_path == PurePosixPath("/"):
67
+ self.parent = self
68
+ else:
69
+ parent_path = self._posix_path.parent
70
+ new_uri = f"{self.scheme}://{str(parent_path)[1:]}"
71
+ self.parent = URI(new_uri, scheme=self.scheme)
72
+
73
+ def is_dir(self) -> bool:
74
+ """Check if the URI points to a directory."""
75
+ return self.path.endswith("/")
76
+
77
+ def __truediv__(self, other: PathLike[str] | str) -> URI:
78
+ """
79
+ Join this URI with another path component.
80
+
81
+ Args:
82
+ other: Path component to join
83
+
84
+ Returns:
85
+ A new URI with the joined path
86
+
87
+ Raises:
88
+ JoinURIError: If trying to join an absolute path
89
+ TypeError: If the path component is not a string or PathLike
90
+ """
91
+ if str(other).startswith("/"):
92
+ raise JoinURIError(f"Cannot join absolute path '{other}' to URI")
93
+
94
+ is_dir = str(other).endswith("/")
95
+ new_path = self._posix_path / other
96
+
97
+ for part in new_path.parts:
98
+ if part == "..":
99
+ new_path = new_path.parent
100
+ elif part != ".":
101
+ new_path = new_path / part
102
+
103
+ new_uri = f"{self.scheme}://{new_path.relative_to('/')}" # we need to strip the starting '/'
104
+ new_uri = new_uri if not is_dir else f"{new_uri}/" # and append slash if the added path was a dir
105
+ return URI(new_uri, scheme=self.scheme)
106
+
107
+ def __str__(self) -> str:
108
+ """Return the original URI string."""
109
+ return self._raw
110
+
111
+ def __repr__(self) -> str:
112
+ """Return the original URI string."""
113
+ return f"URI({self._raw})"
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from cli.cloud.storage.uri import URI, InvalidURIError
6
+
7
+
8
+ def uri(path: str) -> UriOrPath:
9
+ """
10
+ Parses a path and returns a UriOrPath object.
11
+
12
+ NOTE: The name of this function is important as it is used by Typer to determine the name/type of the argument
13
+ """
14
+ try:
15
+ p: Path | URI = URI(path)
16
+ except InvalidURIError:
17
+ p = Path(path)
18
+ return UriOrPath(p)
19
+
20
+
21
+ class UriOrPath:
22
+ """
23
+ Union type for handling local and remote paths for Remotive Cloud Storage
24
+
25
+ Note: This custom type only exists because Typer currently does not support union types
26
+ TODO: Move to commands package when refactored
27
+ """
28
+
29
+ def __init__(self, value: Path | URI) -> None:
30
+ self._value = value
31
+
32
+ @property
33
+ def uri(self) -> URI | None:
34
+ return self._value if isinstance(self._value, URI) else None
35
+
36
+ @property
37
+ def path(self) -> Path | None:
38
+ return self._value if isinstance(self._value, Path) else None
39
+
40
+ @property
41
+ def value(self) -> Path | URI:
42
+ return self._value
43
+
44
+ def __str__(self) -> str:
45
+ return str(self._value)
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: remotivelabs-cli
3
- Version: 0.0.31
3
+ Version: 0.0.33
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
@@ -19,7 +19,7 @@ Requires-Dist: python-socketio (>=4.6.1)
19
19
  Requires-Dist: remotivelabs-broker (>=0.1.17,<0.2.0)
20
20
  Requires-Dist: rich (>=13.7.0,<13.8.0)
21
21
  Requires-Dist: trogon (>=0.5.0)
22
- Requires-Dist: typer (>=0.9.0,<0.10.0)
22
+ Requires-Dist: typer (==0.12.5)
23
23
  Requires-Dist: types-requests (>=2.32.0.20240622,<3.0.0.0)
24
24
  Requires-Dist: websocket-client (>=1.6,<2.0)
25
25
  Requires-Dist: zeroconf (>=0.127.0,<0.128.0)
@@ -3,29 +3,35 @@ cli/broker/brokers.py,sha256=oUadEL6xQ4bhXucBH-ZjL67VuERf19kn1g240v_lEpg,3197
3
3
  cli/broker/export.py,sha256=3sG9i6ZwOQW6snu87NSzOL2_giQTYQMzQlpPg7z8n78,4431
4
4
  cli/broker/files.py,sha256=_MVwitQ5Z9-lNDb3biXqnlkKti8rizTEw0nnAViussU,4181
5
5
  cli/broker/lib/__about__.py,sha256=xnZ5V6ZcHW9dhWLWdMzVjYJbEnMKpeXm0_S_mbNzypE,141
6
- cli/broker/lib/broker.py,sha256=iBv-uegVD6awnhkukV50CcZywIybEO3qvI0YU47KcGo,23949
6
+ cli/broker/lib/broker.py,sha256=Ip9Fg3WskADWjaIO-315s0vCMtZxM5jwBFpK0QuuIPI,24515
7
7
  cli/broker/license_flows.py,sha256=qJplaeugkUiypFGPdEIl5Asqlf7W3geJ-wU-QbYMP_8,7216
8
8
  cli/broker/licenses.py,sha256=Ddl243re8RoeP9CoWWbIzwDePQ9l8r7ixmbd1gqn8f0,3973
9
9
  cli/broker/playback.py,sha256=hdDKXGPuIE3gcT-kgQltgn5jsPzK19Yh9hiNcgtkLX0,3992
10
10
  cli/broker/record.py,sha256=Oa6hUpS0Dgnt0f6Ig33vl0Jy8wN7wMXfemaxXWjRVoQ,1414
11
11
  cli/broker/scripting.py,sha256=8577_C6siOk90s4G1ItIfAoFIUAkS0ItUl5kqR0cD-k,3792
12
- cli/broker/signals.py,sha256=llok_jUGWOzAiiQUK54uRDnDuonBOAYBDbPdnzCFdog,7075
12
+ cli/broker/signals.py,sha256=KflNaVFgsKFi1suec7fSkCfGhcJ0LfPTMRGVQUlsU-0,7058
13
13
  cli/cloud/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- cli/cloud/auth.py,sha256=RBTDUGRBVsK28u-aeqzRIzHnW7FqH4KADlVlQEgBCww,2534
14
+ cli/cloud/auth/__init__.py,sha256=MtQ01-n8CgZb9Y_SvxwZUgj44Yo0dFAU3_XwhQiUYtw,54
15
+ cli/cloud/auth/cmd.py,sha256=T2o-T3KYQPZU5Ujj7IuOYA7icbv930hks14YA-4Gh1s,1077
16
+ cli/cloud/auth/login.py,sha256=yhehYM97tcS6A7srVU4Olg8s58H0JJzfSl7yWp9a1VI,1607
15
17
  cli/cloud/auth_tokens.py,sha256=0U60Gk2-TnAUff5anZmTB1rOEninNvYy1o5ihCqgj8A,4525
16
18
  cli/cloud/brokers.py,sha256=DNj79MTkPylKUQbr-iPUhQgfNJLAW8UehnvgpEmNH_k,3890
17
- cli/cloud/cloud_cli.py,sha256=09YCHs8IivYsVJOsxlM5OMEqBdq3QUXtDsktcO8Kjyw,1263
18
- cli/cloud/configs.py,sha256=xg3J-kaS-Pp0p9otV2cWl_oOWJzs_jZhXwFHz0gQxvc,4625
19
- cli/cloud/filestorage.py,sha256=BZbSk9abuLYw9TjGsQpfYMobtE4VLgXFrWD1sVvGolY,5621
19
+ cli/cloud/cloud_cli.py,sha256=c_JMjAOsJnAw4TM7gkPWmEfrrQly6lAppvfHAmaUGmo,1262
20
+ cli/cloud/configs.py,sha256=bZzZF-yNkZkfJK5baU0Xh_vQRrJkmuzEv8xx4_IkXZ8,4714
20
21
  cli/cloud/organisations.py,sha256=txKQmSQEpTmeqlqngai8pwgQQEvRgeDd0dT_VzZ7RNc,752
21
22
  cli/cloud/projects.py,sha256=YrwPJClC2Sq_y1HjPd_tzaiv4GEnnsXSXHBhtQCPdK0,1431
22
- cli/cloud/recordings.py,sha256=jai5Gim28UmZFGniUI9qKDwtLoi2Nllv4eyPeIk3OAc,25366
23
+ cli/cloud/recordings.py,sha256=RK-CfYEZ_K4o_nOuRBQ6JCsNpTc8yGWQUMV_BN4aWQA,25315
23
24
  cli/cloud/recordings_playback.py,sha256=PRzftmvG2iePrL9f6qTEXVOnyJ-etcyzn5w9CCxcSto,11539
24
- cli/cloud/rest_helper.py,sha256=lZp0NjQ8yOaggQGNiqNxHex_YFOmuq0rnLPtpLq3Z3Q,11470
25
- cli/cloud/resumable_upload.py,sha256=5R6TLq9j8h-qs5bgGFC-lVaEsTI4DoAiTsZcROVtqgw,3688
25
+ cli/cloud/rest_helper.py,sha256=_3RLk8C101PH5pCq5GhCCYLSiS2TZtXh69Yt5TfiJsY,11471
26
+ cli/cloud/resumable_upload.py,sha256=8lEIdncJZoTZzNsQVHH3gm_GunxEmN5JbmWX7awy3p4,3713
26
27
  cli/cloud/sample_recordings.py,sha256=OVX32U1dkkkJZysbgr5Dy515oOQKnwBAbZYzV_QUu1g,690
27
28
  cli/cloud/service_account_tokens.py,sha256=263u1bRmBKfYsxL6TV6YjReUBUaVHWc3ETCd7AS3DTU,2297
28
29
  cli/cloud/service_accounts.py,sha256=XOIPobUamCLIaufjyvb33XJDwy6uRqW5ZljZx3GYEfo,1659
30
+ cli/cloud/storage/__init__.py,sha256=y6V0QswTVY7lHcy5kVUbNKIPc5tyWDqvhakcRFKV6HA,167
31
+ cli/cloud/storage/cmd.py,sha256=Uq8tRJw9AYD8V4F5SeqUGKGfAZRdB-J3vHDdCbhfhCg,2931
32
+ cli/cloud/storage/copy.py,sha256=thjdhqnFHSstsA5Iidz74c2o4zHfEaDXfRUGOpjWh1o,3228
33
+ cli/cloud/storage/uri.py,sha256=QZCus--KJQlVwGCOzZqiglvj8VvSRKxfVvN33Pilgyg,3616
34
+ cli/cloud/storage/uri_or_path.py,sha256=rTjg9G0h8-jb4QUlza1YMqqs99ifrydnEL7o7rmidKQ,1166
29
35
  cli/connect/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
36
  cli/connect/connect.py,sha256=U--6dtHxUlvE81J37rABFez4TbF7AXWOpZYZnL7sPMY,3994
31
37
  cli/connect/protopie/protopie.py,sha256=KBMbBwdkUVgV2X7AXTHweVqYVHv4akG875FVc36gsyg,6349
@@ -33,11 +39,12 @@ cli/errors.py,sha256=CXYArw1W82bRFwJkJ3tD-Ek1huKeah502DGMvPxHYFo,1366
33
39
  cli/remotive.py,sha256=z834JeOwENyUM4bS74_zE95sGwu1efgfDVtCLKV5rV0,1789
34
40
  cli/settings.py,sha256=A5rtp_1oix7Com5aHCAHdwJqxoV2LgxpYXwCe40v7oY,2072
35
41
  cli/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
+ cli/tools/can/RemotiveLabs.can1.log,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
43
  cli/tools/can/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
44
  cli/tools/can/can.py,sha256=8uATViSFlpkdSiIm4fzbuQi1_m7V9Pym-K17TaJQRHU,2262
38
45
  cli/tools/tools.py,sha256=0KU-hXR1f9xHP4BOG9A9eXfmICLmNuQCOU8ueF6iGg0,198
39
- remotivelabs_cli-0.0.31.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
40
- remotivelabs_cli-0.0.31.dist-info/METADATA,sha256=hXSuC1H53XacGz8LX3D4ySgdUIOncQG7iIWQEm5NdkI,1318
41
- remotivelabs_cli-0.0.31.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
42
- remotivelabs_cli-0.0.31.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
43
- remotivelabs_cli-0.0.31.dist-info/RECORD,,
46
+ remotivelabs_cli-0.0.33.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
47
+ remotivelabs_cli-0.0.33.dist-info/METADATA,sha256=i6JbcH5fZlS-46mb2wqWF4xCpERjMpkGLL57IPXrUcI,1311
48
+ remotivelabs_cli-0.0.33.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
49
+ remotivelabs_cli-0.0.33.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
50
+ remotivelabs_cli-0.0.33.dist-info/RECORD,,
cli/cloud/filestorage.py DELETED
@@ -1,170 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os.path
5
- import sys
6
- from pathlib import Path
7
-
8
- import typer
9
-
10
- from ..errors import ErrorPrinter
11
- from . import resumable_upload as upload
12
- from .rest_helper import RestHelper as Rest
13
-
14
- app = typer.Typer(
15
- rich_markup_mode="rich",
16
- help="""
17
- Manage files ([yellow]Beta feature not available for all customers[/yellow])
18
-
19
- Copy file from local to remote storage and vice versa, list and delete files.
20
-
21
- """,
22
- )
23
-
24
-
25
- @app.command(name="ls")
26
- def list_files(
27
- project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
28
- prefix: str = typer.Argument(default="rcs://", help="Remote storage path"),
29
- ) -> None:
30
- """
31
- Listing remote files
32
-
33
- This will list files and directories in project top level directory
34
- remotive cloud storage ls rcs://
35
-
36
- This will list all files and directories matching the path
37
- remotive cloud storage ls rcs://fileOrDirectoryPrefix
38
-
39
- This will list all files and directories in the specified directory
40
- remotive cloud storage ls rcs://fileOrDirectory/
41
- """
42
-
43
- if prefix.startswith("rcs://"):
44
- prefix = __check_rcs_path(prefix)
45
- else:
46
- ErrorPrinter.print_hint("Path must start with rcs://")
47
- sys.exit(1)
48
-
49
- Rest.handle_get(
50
- f"/api/project/{project}/files/storage{prefix}",
51
- )
52
-
53
-
54
- @app.command(name="rm")
55
- def delete_file(
56
- project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
57
- path: str = typer.Argument(default=..., help="Remote storage path to file to delete"),
58
- ) -> None:
59
- """
60
- [red]Deletes[/red] a file from remote storage, this cannot be undone :fire:
61
-
62
- [white]remotive cloud storage rm rcs://directory/filename[/white]
63
- """
64
- if path.startswith("rcs://"):
65
- prefix = __check_rcs_path(path)
66
- else:
67
- ErrorPrinter.print_hint("Path must start with rcs://")
68
- sys.exit(1)
69
-
70
- Rest.handle_delete(
71
- f"/api/project/{project}/files/storage{prefix}",
72
- )
73
-
74
-
75
- @app.command(name="cp")
76
- def copy_file( # noqa: C901 # type: ignore[too-many-branches] # pylint: disable=no-member
77
- source: str = typer.Argument(default=..., help="Remote or local path to source file"),
78
- dest: str = typer.Argument(default=..., help="Remote or local path to destination file"),
79
- overwrite: bool = typer.Option(default=False, help="Overwrite existing file on RCS"),
80
- project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
81
- ) -> None:
82
- """
83
- Copies a file to or from remote storage
84
-
85
- remotive cloud storage cp rcs://dir/filename .
86
- remotive cloud storage cp rcs://dir/filename filename
87
-
88
- remotive cloud storage cp filename rcs://dir/
89
- remotive cloud storage cp filename rcs://dir/filename
90
- """
91
-
92
- if not source.startswith("rcs://") and not dest.startswith("rcs://"):
93
- ErrorPrinter.print_hint("Source or destination path must be an rcs:// path")
94
- sys.exit(2)
95
-
96
- if source.startswith("rcs://") and dest.startswith("rcs://"):
97
- ErrorPrinter.print_hint("Currently one of source and destination path must be a local path")
98
- sys.exit(2)
99
-
100
- if source.startswith("rcs://"):
101
- __copy_to_local(source=source, dest=dest, project=project)
102
- else:
103
- path = Path(source)
104
- if path.is_dir():
105
- for file_path in path.rglob("*"):
106
- if file_path.is_file():
107
- __copy_to_remote(source=source, dest=dest, project=project, overwrite=overwrite)
108
- sys.exit(1)
109
- else:
110
- __copy_to_remote(source=source, dest=dest, project=project, overwrite=overwrite)
111
-
112
-
113
- def __copy_to_remote(source: str, dest: str, project: str, overwrite: bool) -> None:
114
- path = Path(source)
115
- if path.is_dir():
116
- sys.exit(1)
117
-
118
- if not path.exists():
119
- ErrorPrinter.print_hint("Source file does not exist")
120
- sys.exit(1)
121
- filename = source.rsplit("/", 1)[-1]
122
- rcs_path = __check_rcs_path(dest)
123
- if rcs_path.endswith("/"):
124
- rcs_path = rcs_path + filename
125
- upload_options = {"overwrite": "always" if overwrite else "never"}
126
- res = Rest.handle_post(f"/api/project/{project}/files/storage{rcs_path}", return_response=True, body=json.dumps(upload_options))
127
- if res is None:
128
- return
129
- json_res = res.json()
130
- url = json_res["url"]
131
- headers = json_res["headers"]
132
- try:
133
- upload.upload_signed_url(url, source, headers)
134
- except IsADirectoryError:
135
- ErrorPrinter.print_hint(f"Supplied source file '{source}' is a directory but must be a file")
136
-
137
-
138
- def __copy_to_local(source: str, dest: str, project: str) -> None:
139
- rcs_path = __check_rcs_path(source)
140
- filename = source.rsplit("/", 1)[-1]
141
- path = Path(dest)
142
- if path.is_dir():
143
- if not path.exists():
144
- ErrorPrinter.print_generic_error("Destination directory does not exist")
145
- sys.exit(1)
146
- else:
147
- dest = os.path.join(path.absolute(), filename)
148
-
149
- else:
150
- if not path.parent.is_dir() or not path.parent.exists():
151
- ErrorPrinter.print_generic_error("Destination directory does not exist")
152
- sys.exit(1)
153
- dest = str(Path(dest).absolute())
154
-
155
- res = Rest.handle_get(
156
- f"/api/project/{project}/files/storage{rcs_path}?download=true",
157
- return_response=True,
158
- )
159
- if res is None:
160
- return
161
-
162
- Rest.download_file(save_file_name=dest, url=res.text)
163
-
164
-
165
- def __check_rcs_path(path: str) -> str:
166
- rcs_path = path.replace("rcs://", "/")
167
- if rcs_path.startswith("/."):
168
- ErrorPrinter.print_hint("Invalid path")
169
- sys.exit(1)
170
- return rcs_path