Skip to content

Commit b50c8c8

Browse files
tchatonethanwharristhomaspre-commit-ci[bot]
authored andcommitted
[App] Enable listing at project level 2/n (#16622)
Co-authored-by: Ethan Harris <[email protected]> Co-authored-by: thomas <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> (cherry picked from commit 03bb29c)
1 parent 56a6e49 commit b50c8c8

File tree

6 files changed

+189
-74
lines changed

6 files changed

+189
-74
lines changed

src/lightning_app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
1313

1414
- Added FileSystem abstraction to simply manipulation of files ([#16581](https://github.com/Lightning-AI/lightning/pull/16581))
1515

16+
- Enabled `ls` and `cp` (download) at project level ([#16622](https://github.com/Lightning-AI/lightning/pull/16622))
1617
- Added Storage Commands ([#16606](https://github.com/Lightning-AI/lightning/pull/16606))
1718
* `ls`: List files from your Cloud Platform Filesystem
1819
* `cd`: Change the current directory within your Cloud Platform filesystem (terminal session based)

src/lightning_app/cli/commands/cd.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@ def cd(path: Optional[Union[Tuple[str], str]]) -> None:
3737

3838
root = "/"
3939

40-
live.stop()
41-
4240
if isinstance(path, Tuple) and len(path) > 0:
4341
path = " ".join(path)
4442

src/lightning_app/cli/commands/cp.py

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,13 @@
1818
from functools import partial
1919
from multiprocessing.pool import ApplyResult
2020
from pathlib import Path
21-
from time import sleep
22-
from typing import Optional, Tuple
21+
from typing import Optional, Tuple, Union
2322

2423
import click
2524
import requests
2625
import rich
2726
import urllib3
28-
from lightning_cloud.openapi import IdArtifactsBody
27+
from lightning_cloud.openapi import Externalv1LightningappInstance, IdArtifactsBody, V1CloudSpace
2928
from rich.live import Live
3029
from rich.progress import BarColumn, DownloadColumn, Progress, Task, TextColumn
3130
from rich.spinner import Spinner
@@ -60,8 +59,8 @@ def cp(src_path: str, dst_path: str, r: bool = False, recursive: bool = False) -
6059

6160
client = LightningClient()
6261

63-
src_path, src_remote = _sanetize_path(src_path, pwd)
64-
dst_path, dst_remote = _sanetize_path(dst_path, pwd)
62+
src_path, src_remote = _sanitize_path(src_path, pwd)
63+
dst_path, dst_remote = _sanitize_path(dst_path, pwd)
6564

6665
if src_remote and dst_remote:
6766
return _error_and_exit("Moving files remotely isn't supported yet. Please, open a Github issue.")
@@ -104,9 +103,6 @@ def _upload_files(live, client: LightningClient, local_src: str, remote_dst: str
104103

105104
live.stop()
106105

107-
# Sleep to avoid rich live collision.
108-
sleep(1)
109-
110106
progress = _get_progress_bar()
111107

112108
total_size = sum([Path(path).stat().st_size for path in upload_paths])
@@ -141,14 +137,15 @@ def _upload(source_file: str, presigned_url: ApplyResult, progress: Progress, ta
141137

142138

143139
def _download_files(live, client, remote_src: str, local_dst: str, pwd: str):
144-
project_id, app_id = _get_project_app_ids(pwd)
140+
project_id, lit_resource = _get_project_id_and_resource(pwd)
145141

146142
download_paths = []
147143
download_urls = []
148144
total_size = []
149145

150-
response = client.lightningapp_instance_service_list_lightningapp_instance_artifacts(project_id, app_id)
151-
for artifact in response.artifacts:
146+
prefix = _get_prefix("/".join(pwd.split("/")[3:]), lit_resource)
147+
148+
for artifact in _collect_artifacts(client, project_id, prefix, include_download_url=True):
152149
path = os.path.join(local_dst, artifact.filename.replace(remote_src, ""))
153150
path = Path(path).resolve()
154151
os.makedirs(path.parent, exist_ok=True)
@@ -158,14 +155,15 @@ def _download_files(live, client, remote_src: str, local_dst: str, pwd: str):
158155

159156
live.stop()
160157

161-
# Sleep to avoid rich live collision.
162-
sleep(1)
158+
if not download_paths:
159+
print("There were no files to download.")
160+
return
163161

164162
progress = progress = _get_progress_bar()
165163

166164
progress.start()
167165

168-
task_id = progress.add_task("download", filename=path, total=sum(total_size))
166+
task_id = progress.add_task("download", filename="", total=sum(total_size))
169167

170168
_download_file_fn = partial(_download_file, progress=progress, task_id=task_id)
171169

@@ -194,7 +192,7 @@ def _download_file(path: str, url: str, progress: Progress, task_id: Task) -> No
194192
progress.update(task_id, advance=len(chunk))
195193

196194

197-
def _sanetize_path(path: str, pwd: str) -> Tuple[str, bool]:
195+
def _sanitize_path(path: str, pwd: str) -> Tuple[str, bool]:
198196
is_remote = _is_remote(path)
199197
if is_remote:
200198
path = _remove_remote(path)
@@ -218,6 +216,7 @@ def _error_and_exit(msg: str) -> str:
218216
sys.exit(0)
219217

220218

219+
# TODO: To be removed when upload is supported for CloudSpaces.
221220
def _get_project_app_ids(pwd: str) -> Tuple[str, str]:
222221
"""Convert a root path to a project id and app id."""
223222
# TODO: Handle project level
@@ -235,6 +234,35 @@ def _get_project_app_ids(pwd: str) -> Tuple[str, str]:
235234
return project_id, lit_app.id
236235

237236

237+
def _get_project_id_and_resource(pwd: str) -> Tuple[str, Union[Externalv1LightningappInstance, V1CloudSpace]]:
238+
"""Convert a root path to a project id and app id."""
239+
# TODO: Handle project level
240+
project_name, resource_name, *_ = pwd.split("/")[1:3]
241+
242+
# 1. Collect the projects of the user
243+
client = LightningClient()
244+
projects = client.projects_service_list_memberships()
245+
project_id = [project.project_id for project in projects.memberships if project.name == project_name][0]
246+
247+
# 2. Collect resources
248+
lit_apps = client.lightningapp_instance_service_list_lightningapp_instances(project_id=project_id).lightningapps
249+
250+
lit_cloud_spaces = client.cloud_space_service_list_cloud_spaces(project_id=project_id).cloudspaces
251+
252+
lit_ressources = [lit_resource for lit_resource in lit_cloud_spaces if lit_resource.name == resource_name]
253+
254+
if len(lit_ressources) == 0:
255+
256+
lit_ressources = [lit_resource for lit_resource in lit_apps if lit_resource.name == resource_name]
257+
258+
if len(lit_ressources) == 0:
259+
260+
print(f"ERROR: There isn't any Lightning Ressource matching the name {resource_name}.")
261+
sys.exit(0)
262+
263+
return project_id, lit_ressources[0]
264+
265+
238266
def _get_progress_bar():
239267
return Progress(
240268
TextColumn("[bold blue]{task.description}", justify="left"),

src/lightning_app/cli/commands/ls.py

Lines changed: 106 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import click
2020
import rich
21+
from lightning_cloud.openapi import Externalv1LightningappInstance
2122
from rich.console import Console
2223
from rich.live import Live
2324
from rich.spinner import Spinner
@@ -44,9 +45,7 @@ def ls(path: Optional[str] = None) -> List[str]:
4445

4546
root = "/"
4647

47-
with Live(Spinner("point", text=Text("pending...", style="white")), transient=True) as live:
48-
49-
live.stop()
48+
with Live(Spinner("point", text=Text("pending...", style="white")), transient=True):
5049

5150
if not os.path.exists(_LIGHTNING_CONNECTION_FOLDER):
5251
os.makedirs(_LIGHTNING_CONNECTION_FOLDER)
@@ -76,45 +75,74 @@ def ls(path: Optional[str] = None) -> List[str]:
7675

7776
lit_apps = client.lightningapp_instance_service_list_lightningapp_instances(project_id=project_id).lightningapps
7877

78+
lit_cloud_spaces = client.cloud_space_service_list_cloud_spaces(project_id=project_id).cloudspaces
79+
7980
if len(splits) == 1:
80-
app_names = sorted([lit_app.name for lit_app in lit_apps])
81-
_print_names_with_colors(app_names, [_FOLDER_COLOR] * len(app_names))
82-
return app_names
81+
apps = [lit_app.name for lit_app in lit_apps]
82+
cloud_spaces = [lit_cloud_space.name for lit_cloud_space in lit_cloud_spaces]
83+
ressource_names = sorted(set(cloud_spaces + apps))
84+
_print_names_with_colors(ressource_names, [_FOLDER_COLOR] * len(ressource_names))
85+
return ressource_names
86+
87+
lit_ressources = [lit_resource for lit_resource in lit_cloud_spaces if lit_resource.name == splits[1]]
88+
89+
if len(lit_ressources) == 0:
8390

84-
lit_apps = [lit_app for lit_app in lit_apps if lit_app.name == splits[1]]
91+
lit_ressources = [lit_resource for lit_resource in lit_apps if lit_resource.name == splits[1]]
8592

86-
if len(lit_apps) != 1:
87-
print(f"ERROR: There isn't any Lightning App matching the name {splits[1]}.")
88-
sys.exit(0)
93+
if len(lit_ressources) == 0:
8994

90-
lit_app = lit_apps[0]
95+
print(f"ERROR: There isn't any Lightning Ressource matching the name {splits[1]}.")
96+
sys.exit(0)
97+
98+
lit_resource = lit_ressources[0]
99+
100+
app_paths = []
101+
app_colors = []
102+
103+
cloud_spaces_paths = []
104+
cloud_spaces_colors = []
91105

92-
paths = []
93-
colors = []
94106
depth = len(splits)
95-
subpath = "/".join(splits[2:])
96-
# TODO: Replace with project level endpoints
97-
for artifact in _collect_artifacts(client, project_id, lit_app.id):
98-
path = os.path.join(project_id, lit_app.name, artifact.filename)
107+
108+
prefix = "/".join(splits[2:])
109+
prefix = _get_prefix(prefix, lit_resource)
110+
111+
for artifact in _collect_artifacts(client=client, project_id=project_id, prefix=prefix):
112+
113+
if str(artifact.filename).startswith("/"):
114+
artifact.filename = artifact.filename[1:]
115+
116+
path = os.path.join(project_id, prefix[1:], artifact.filename)
117+
99118
artifact_splits = path.split("/")
100119

101-
if len(artifact_splits) < depth + 1:
120+
if len(artifact_splits) <= depth + 1:
102121
continue
103122

104-
if not str(artifact.filename).startswith(subpath):
105-
continue
123+
path = artifact_splits[depth + 1]
106124

107-
path = artifact_splits[depth]
125+
paths = app_paths if isinstance(lit_resource, Externalv1LightningappInstance) else cloud_spaces_paths
126+
colors = app_colors if isinstance(lit_resource, Externalv1LightningappInstance) else cloud_spaces_colors
108127

109128
if path not in paths:
110129
paths.append(path)
111130

112131
# display files otherwise folders
113132
colors.append(_FILE_COLOR if len(artifact_splits) == depth + 1 else _FOLDER_COLOR)
114133

115-
_print_names_with_colors(paths, colors)
134+
if app_paths and cloud_spaces_paths:
135+
if app_paths:
136+
rich.print("Lightning App")
137+
_print_names_with_colors(app_paths, app_colors)
138+
139+
if cloud_spaces_paths:
140+
rich.print("Lightning CloudSpaces")
141+
_print_names_with_colors(cloud_spaces_paths, cloud_spaces_colors)
142+
else:
143+
_print_names_with_colors(app_paths + cloud_spaces_paths, app_colors + cloud_spaces_colors)
116144

117-
return paths
145+
return app_paths + cloud_spaces_paths
118146

119147

120148
def _add_colors(filename: str, color: Optional[str] = None) -> str:
@@ -156,21 +184,64 @@ def _print_names_with_colors(names: List[str], colors: List[str], padding: int =
156184
def _collect_artifacts(
157185
client: LightningClient,
158186
project_id: str,
159-
app_id: str,
187+
prefix: str = "",
160188
page_token: Optional[str] = "",
189+
cluster_id: Optional[str] = None,
190+
page_size: int = 100_000,
161191
tokens=None,
192+
include_download_url: bool = False,
162193
) -> Generator:
163194
if tokens is None:
164195
tokens = []
165196

166-
if page_token in tokens:
167-
return
168-
169-
response = client.lightningapp_instance_service_list_lightningapp_instance_artifacts(
170-
project_id, app_id, page_token=page_token
171-
)
172-
yield from response.artifacts
173-
174-
if response.next_page_token != "":
175-
tokens.append(page_token)
176-
yield from _collect_artifacts(client, project_id, app_id, page_token=response.next_page_token, tokens=tokens)
197+
if cluster_id is None:
198+
clusters = client.projects_service_list_project_cluster_bindings(project_id)
199+
for cluster in clusters.clusters:
200+
yield from _collect_artifacts(
201+
client,
202+
project_id,
203+
prefix=prefix,
204+
cluster_id=cluster.cluster_id,
205+
page_token=page_token,
206+
tokens=tokens,
207+
page_size=page_size,
208+
include_download_url=include_download_url,
209+
)
210+
else:
211+
212+
if page_token in tokens:
213+
return
214+
215+
response = client.lightningapp_instance_service_list_project_artifacts(
216+
project_id,
217+
prefix=prefix,
218+
cluster_id=cluster_id,
219+
page_token=page_token,
220+
include_download_url=include_download_url,
221+
page_size=str(page_size),
222+
)
223+
yield from response.artifacts
224+
225+
if response.next_page_token != "":
226+
tokens.append(page_token)
227+
yield from _collect_artifacts(
228+
client,
229+
project_id,
230+
prefix=prefix,
231+
cluster_id=cluster_id,
232+
page_token=response.next_page_token,
233+
tokens=tokens,
234+
)
235+
236+
237+
def _add_resource_prefix(prefix: str, resource_path: str):
238+
if resource_path in prefix:
239+
return prefix
240+
return "/" + os.path.join(resource_path, prefix)
241+
242+
243+
def _get_prefix(prefix: str, lit_resource) -> str:
244+
if isinstance(lit_resource, Externalv1LightningappInstance):
245+
return _add_resource_prefix(prefix, f"lightningapps/{lit_resource.id}")
246+
247+
return _add_resource_prefix(prefix, f"cloudspaces/{lit_resource.id}")

tests/tests_app/cli/test_cp.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ def test_cp_cloud_to_local(tmpdir, monkeypatch):
7171
memberships=[V1Membership(name="project-0")]
7272
)
7373

74+
clusters = MagicMock()
75+
clusters.clusters = [MagicMock()]
76+
client.projects_service_list_project_cluster_bindings.return_value = clusters
77+
7478
client.lightningapp_instance_service_list_lightningapp_instances.return_value = V1ListLightningappInstancesResponse(
7579
lightningapps=[
7680
Externalv1LightningappInstance(
@@ -80,7 +84,7 @@ def test_cp_cloud_to_local(tmpdir, monkeypatch):
8084
]
8185
)
8286

83-
client.lightningapp_instance_service_list_lightningapp_instance_artifacts.return_value = (
87+
client.lightningapp_instance_service_list_project_artifacts.return_value = (
8488
V1ListLightningappInstanceArtifactsResponse(
8589
artifacts=[
8690
V1LightningappInstanceArtifact(

0 commit comments

Comments
 (0)