remotivelabs-cli 0.0.24__py3-none-any.whl → 0.0.25__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/cloud/cloud_cli.py CHANGED
@@ -2,7 +2,7 @@ import json
2
2
 
3
3
  import typer
4
4
 
5
- from . import auth, brokers, configs, projects, recordings, sample_recordings, service_accounts
5
+ from . import auth, brokers, configs, filestorage, projects, recordings, sample_recordings, service_accounts
6
6
  from . import rest_helper as rest
7
7
 
8
8
  app = typer.Typer()
@@ -32,6 +32,7 @@ app.add_typer(auth.app, name="auth")
32
32
  app.add_typer(brokers.app, name="brokers", help="Manage cloud broker lifecycle")
33
33
  app.add_typer(recordings.app, name="recordings", help="Manage recordings")
34
34
  app.add_typer(configs.app, name="signal-databases", help="Manage signal databases")
35
+ app.add_typer(filestorage.app, name="storage")
35
36
  app.add_typer(service_accounts.app, name="service-accounts", help="Manage project service account keys")
36
37
  app.add_typer(sample_recordings.app, name="samples", help="Manage sample recordings")
37
38
 
@@ -0,0 +1,143 @@
1
+ import os.path
2
+ from pathlib import Path
3
+
4
+ import typer
5
+
6
+ from ..errors import ErrorPrinter
7
+ from . import rest_helper as rest
8
+ from . import resumable_upload as upload
9
+
10
+ app = typer.Typer(
11
+ rich_markup_mode="rich",
12
+ help="""
13
+ Manage files ([yellow]Beta feature not available for all customers[/yellow])
14
+
15
+ Copy file from local to remote storage and vice versa, list and delete files.
16
+
17
+ """,
18
+ )
19
+
20
+
21
+ @app.command(name="ls")
22
+ def list_files(
23
+ project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
24
+ prefix: str = typer.Argument(default="rcs://", help="Remote storage path"),
25
+ ):
26
+ """
27
+ Listing remote files
28
+
29
+ This will list files and directories in project top level directory
30
+ remotive cloud storage ls rcs://
31
+
32
+ This will list all files and directories matching the path
33
+ remotive cloud storage ls rcs://fileOrDirectoryPrefix
34
+
35
+ This will list all files and directories in the specified directory
36
+ remotive cloud storage ls rcs://fileOrDirectory/
37
+ """
38
+
39
+ if prefix.startswith("rcs://"):
40
+ prefix = __check_rcs_path(prefix)
41
+ else:
42
+ ErrorPrinter.print_hint("Path must start with rcs://")
43
+ exit(1)
44
+
45
+ rest.handle_get(
46
+ f"/api/project/{project}/files/storage{prefix}",
47
+ )
48
+
49
+
50
+ @app.command(name="rm")
51
+ def delete_file(
52
+ project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
53
+ path: str = typer.Argument(default=..., help="Remote storage path to file to delete"),
54
+ ):
55
+ """
56
+ [red]Deletes[/red] a file from remote storage, this cannot be undone :fire:
57
+
58
+ [white]remotive cloud storage rm rcs://directory/filename[/white]
59
+ """
60
+ if path.startswith("rcs://"):
61
+ prefix = __check_rcs_path(path)
62
+ else:
63
+ ErrorPrinter.print_hint("Path must start with rcs://")
64
+ exit(1)
65
+
66
+ rest.handle_delete(
67
+ f"/api/project/{project}/files/storage{prefix}",
68
+ )
69
+
70
+
71
+ @app.command(name="cp")
72
+ def copy_file(
73
+ source: str = typer.Argument(default=..., help="Remote or local path to source file"),
74
+ dest: str = typer.Argument(default=..., help="Remote or local path to destination file"),
75
+ project: str = typer.Option(..., help="Project ID", envvar="REMOTIVE_CLOUD_PROJECT"),
76
+ ):
77
+ """
78
+ Copies a file to or from remote storage
79
+
80
+ remotive cloud storage cp rcs://dir/filename .
81
+ remotive cloud storage cp rcs://dir/filename filename
82
+
83
+ remotive cloud storage cp filename rcs://dir/
84
+ remotive cloud storage cp filename rcs://dir/filename
85
+ """
86
+
87
+ if not source.startswith("rcs://") and not dest.startswith("rcs://"):
88
+ ErrorPrinter.print_hint("Source or destination path must be an rcs:// path")
89
+ exit(2)
90
+
91
+ if source.startswith("rcs://") and dest.startswith("rcs://"):
92
+ ErrorPrinter.print_hint("Currently one of source and destination path must be a local path")
93
+ exit(2)
94
+
95
+ if source.startswith("rcs://"):
96
+ rcs_path = __check_rcs_path(source)
97
+ filename = source.rsplit("/", 1)[-1]
98
+ path = Path(dest)
99
+ if path.is_dir():
100
+ if not path.exists():
101
+ ErrorPrinter.print_generic_error("Destination directory does not exist")
102
+ exit(1)
103
+ else:
104
+ dest = os.path.join(path.absolute(), filename)
105
+
106
+ else:
107
+ if not path.parent.is_dir() or not path.parent.exists():
108
+ ErrorPrinter.print_generic_error("Destination directory does not exist")
109
+ exit(1)
110
+ dest = Path(dest).absolute()
111
+
112
+ res = rest.handle_get(
113
+ f"/api/project/{project}/files/storage{rcs_path}?download=true",
114
+ return_response=True,
115
+ )
116
+
117
+ rest.download_file(save_file_name=dest, url=res.text)
118
+
119
+ else:
120
+ path = Path(source)
121
+ if not path.exists():
122
+ ErrorPrinter.print_hint("Source file does not exist")
123
+ exit(1)
124
+ filename = source.rsplit("/", 1)[-1]
125
+ rcs_path = __check_rcs_path(dest)
126
+ if rcs_path.endswith("/"):
127
+ rcs_path = rcs_path + filename
128
+ res = rest.handle_post(f"/api/project/{project}/files/storage{rcs_path}", return_response=True)
129
+ json = res.json()
130
+ url = json["url"]
131
+ content_type = json["contentType"]
132
+ try:
133
+ upload.upload_signed_url(url, source, content_type)
134
+ except IsADirectoryError:
135
+ ErrorPrinter.print_hint(f"Supplied source file '{source}' is a directory but must be a file")
136
+
137
+
138
+ def __check_rcs_path(path: str):
139
+ rcs_path = path.replace("rcs://", "/")
140
+ if rcs_path.startswith("/."):
141
+ ErrorPrinter.print_hint("Invalid path")
142
+ exit(1)
143
+ return rcs_path
cli/cloud/rest_helper.py CHANGED
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  from typing import Dict, List, Union
11
11
 
12
12
  import requests
13
+ from requests.exceptions import JSONDecodeError
13
14
  from rich.console import Console
14
15
  from rich.progress import Progress, SpinnerColumn, TextColumn, wrap_file
15
16
 
@@ -118,7 +119,11 @@ def check_api_result(response, allow_status_codes: List[int] = None):
118
119
  def print_api_result(response):
119
120
  if response.status_code >= 200 and response.status_code < 300:
120
121
  if len(response.content) > 4:
121
- print(json.dumps(response.json()))
122
+ try:
123
+ print(json.dumps(response.json()))
124
+ except JSONDecodeError:
125
+ err_console.print(":boom: [bold red]Json parse error[/bold red]: Please try again and report if the error persists")
126
+
122
127
  exit(0)
123
128
  else:
124
129
  err_console.print(f":boom: [bold red]Got status code[/bold red]: {response.status_code}")
@@ -205,7 +210,9 @@ def download_file(save_file_name: str, url: str):
205
210
  if download_resp.status_code == 200:
206
211
  content_length = int(download_resp.headers["Content-Length"])
207
212
  with open(save_file_name, "wb") as out_file:
208
- with wrap_file(download_resp.raw, content_length, refresh_per_second=100) as stream_with_progress:
213
+ with wrap_file(
214
+ download_resp.raw, content_length, refresh_per_second=100, description=f"Downloading to {save_file_name}"
215
+ ) as stream_with_progress:
209
216
  shutil.copyfileobj(stream_with_progress, out_file)
210
217
  else:
211
218
  check_api_result(download_resp)
@@ -0,0 +1,61 @@
1
+ import os
2
+
3
+ import requests
4
+ from rich.progress import wrap_file
5
+
6
+ from ..errors import ErrorPrinter
7
+
8
+
9
+ def __get_uploaded_bytes(upload_url):
10
+ headers = {"Content-Range": "bytes */*"}
11
+ response = requests.put(upload_url, headers=headers)
12
+ if response.status_code != 308:
13
+ raise Exception(f"Failed to retrieve upload status: {response.status_code} {response.text}")
14
+
15
+ # Parse the Range header to get the last byte uploaded
16
+ range_header = response.headers.get("Range")
17
+ if range_header:
18
+ last_byte = int(range_header.split("-")[1])
19
+ return last_byte + 1
20
+ return 0
21
+
22
+
23
+ def upload_signed_url(signed_url, source_file_name, content_type):
24
+ """
25
+ Upload file to file storage with signed url and resumable upload.
26
+ Resumable upload will only work with the same URL and not if a new signed URL is requested with the
27
+ same object id.
28
+ :param signed_url:
29
+ :param source_file_name:
30
+ :return:
31
+ """
32
+
33
+ file_size = os.path.getsize(source_file_name)
34
+ headers = {"x-goog-resumable": "start", "content-type": content_type}
35
+ response = requests.post(signed_url, headers=headers)
36
+ if response.status_code not in (200, 201, 308):
37
+ ErrorPrinter.print_generic_error(f"Failed to upload file: {response.status_code} - {response.text}")
38
+ exit(1)
39
+
40
+ upload_url = response.headers["Location"]
41
+
42
+ # Check how many bytes have already been uploaded
43
+ uploaded_bytes = __get_uploaded_bytes(upload_url)
44
+
45
+ # Upload the remaining file in chunks
46
+ # Not sure what a good chunk size is or if we even should have resumable uploads here, probably not..
47
+ chunk_size = 256 * 1024 * 10
48
+ # Upload the file in chunks
49
+ with open(source_file_name, "rb") as f:
50
+ with wrap_file(f, os.stat(source_file_name).st_size, description=f"Uploading {source_file_name}...") as file:
51
+ file.seek(uploaded_bytes) # Seek to the position of the last uploaded byte
52
+ for chunk_start in range(uploaded_bytes, file_size, chunk_size):
53
+ chunk_end = min(chunk_start + chunk_size, file_size) - 1
54
+ chunk = file.read(chunk_end - chunk_start + 1)
55
+ headers = {"Content-Range": f"bytes {chunk_start}-{chunk_end}/{file_size}"}
56
+ response = requests.put(upload_url, headers=headers, data=chunk)
57
+ if response.status_code not in (200, 201, 308):
58
+ ErrorPrinter.print_generic_error(f"Failed to upload file: {response.status_code} - {response.text}")
59
+ exit(1)
60
+
61
+ print(f"File {source_file_name} uploaded successfully.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: remotivelabs-cli
3
- Version: 0.0.24
3
+ Version: 0.0.25
4
4
  Summary: CLI for operating RemotiveCloud and RemotiveBroker
5
5
  Author: Johan Rask
6
6
  Author-email: johan.rask@remotivelabs.com
@@ -15,7 +15,7 @@ Requires-Dist: plotext (>=5.2,<6.0)
15
15
  Requires-Dist: pyjwt (>=2.6,<3.0)
16
16
  Requires-Dist: python-can (>=4.3.1)
17
17
  Requires-Dist: python-socketio (>=4.6.1)
18
- Requires-Dist: remotivelabs-broker (>=0.1.17)
18
+ Requires-Dist: remotivelabs-broker (>=0.1.17,<0.2.0)
19
19
  Requires-Dist: rich (>=13.7.0,<13.8.0)
20
20
  Requires-Dist: trogon (>=0.5.0)
21
21
  Requires-Dist: typer (>=0.9.0,<0.10.0)
@@ -15,12 +15,14 @@ cli/cloud/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
15
15
  cli/cloud/auth.py,sha256=xi47cQWjs6q7Kje4XUbTqEk_BgG3NJ1gMOYPU-_4tgw,3470
16
16
  cli/cloud/auth_tokens.py,sha256=5ox0Pxfij8aTgEcDwRBasaSQvDuLIGCJc-INjmIQN2M,3408
17
17
  cli/cloud/brokers.py,sha256=wa3uMg91IZdrP0tMpTdO9cBIkZHtMHxQ-zEXwFiye_I,4127
18
- cli/cloud/cloud_cli.py,sha256=nRRXFF_IgUMayerxS-h7oqsNe6tt34Q5VeThq8gatEg,1443
18
+ cli/cloud/cloud_cli.py,sha256=oAk8Fw_A0XNjuFztHcMhWKrvGYE-WqYobPXIHBq5ZS4,1503
19
19
  cli/cloud/configs.py,sha256=2p1mCHf5BwYNtwbY0Cbed5t6-79WHGKWU4Fv6LuJ21o,4069
20
+ cli/cloud/filestorage.py,sha256=2921FbflnGA89L7ARv8UXo8S5cOZpNPTGo2xySpo5Ho,4648
20
21
  cli/cloud/projects.py,sha256=-uqltAOficwprOKaPd2R0Itm4sqTz3VJNs9Sc8jtO5k,1369
21
22
  cli/cloud/recordings.py,sha256=QpNb9HWK_LC7Trex8E7RevEy7GTOBZHw0RsgOt_RmUA,23286
22
23
  cli/cloud/recordings_playback.py,sha256=crrwQ3kl8LzCVBan9B1a15t-vhDCNYqLKSVQbLf58Ng,10907
23
- cli/cloud/rest_helper.py,sha256=dUVmykp0_M4pxzI3WiGoURjlaeCe_V_2h7JwnteW69w,7785
24
+ cli/cloud/rest_helper.py,sha256=4wuE7YxFNAaU4BdDIhipfazzuzulmxGGUsvOOn3xv3U,8102
25
+ cli/cloud/resumable_upload.py,sha256=7M7Nm-FEJ7u2c7ezWZfJUOmDbG-Dk1St7xv9I7VDRZY,2586
24
26
  cli/cloud/sample_recordings.py,sha256=g1X6JTxvzWInSP9R1BJsDmL4WqvpEKqjdJR_xT4bo1U,639
25
27
  cli/cloud/service_account_tokens.py,sha256=7vjoMd6Xq7orWCUP7TVUVa86JA0OiX8O10NZcHUE6rM,2294
26
28
  cli/cloud/service_accounts.py,sha256=GCYdYPnP5uWVsg1bTIS67CmoPWDng5dupJHmlThrJ80,1606
@@ -35,8 +37,8 @@ cli/tools/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
35
37
  cli/tools/can/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
36
38
  cli/tools/can/can.py,sha256=kSd1c-nxxXyeKkm19oDILiDBZsKOcpjsUT0T3xox5Qs,2172
37
39
  cli/tools/tools.py,sha256=LwQdWMcJ19pCyKUsVfSB2B3R6ui61NxxFWP0Nrnd5Jk,198
38
- remotivelabs_cli-0.0.24.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
39
- remotivelabs_cli-0.0.24.dist-info/METADATA,sha256=376RvVFRj7s-wIYVbeK8XUi0Ogj8rOIf4jtgwSh-Zms,1224
40
- remotivelabs_cli-0.0.24.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
41
- remotivelabs_cli-0.0.24.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
42
- remotivelabs_cli-0.0.24.dist-info/RECORD,,
40
+ remotivelabs_cli-0.0.25.dist-info/LICENSE,sha256=qDPP_yfuv1fF-u7EfexN-cN3M8aFgGVndGhGLovLKz0,608
41
+ remotivelabs_cli-0.0.25.dist-info/METADATA,sha256=aH4B6GkhSub8xR2cGdJbfjKcm_8BUhkwzgTa6DNCSlI,1231
42
+ remotivelabs_cli-0.0.25.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
43
+ remotivelabs_cli-0.0.25.dist-info/entry_points.txt,sha256=lvDhPgagLqW_KTnLPCwKSqfYlEp-1uYVosRiPjsVj10,45
44
+ remotivelabs_cli-0.0.25.dist-info/RECORD,,