Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions python/lightning_sdk/api/deployment_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,10 @@ def _validate_remote_upload_path_target(*, client: Any, teamspace_id: str, remot
return

parsed = parse_lit_url(normalized)
if parsed.get("owner") is None and parsed.get("teamspace") is None:
# Relative form (lit:///<path>) targets the current teamspace by definition.
return

parsed_teamspace = str(parsed.get("teamspace", "") or "").strip()
if not parsed_teamspace:
raise ValueError("remote_path lit URL must include a non-empty teamspace")
Expand Down
34 changes: 5 additions & 29 deletions python/lightning_sdk/cli/cp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,24 @@
from lightning_sdk.filesystem import Filesystem


def parse_lit_url(url: str) -> str:
"""Parse lit:// URL and extract resource type."""
if not url.startswith("lit://"):
raise ValueError("URL must start with 'lit://'")

path = url.split("://")[-1].split("/")
if len(path) < 3 or not path[2]:
raise ValueError("Invalid lit URL format. Expected 'lit://<owner>/<teamspace>/<resource_type>'")
return path[2].lower()


def _canonicalize_lit_resource_type(url: str) -> str:
"""Normalize the lit:// resource type segment to its canonical lowercase form."""
parse_lit_url(url)
path = url.split("://", maxsplit=1)[-1].split("/")
path[2] = path[2].lower()
return "lit://" + "/".join(path)


def route_cp_operation(source: str, destination: Optional[str], **options: Any) -> None:
"""Route copy operation based on URL structure."""
"""Route copy operation based on URL structure.

Drive paths are passed through untouched — the server owns their validation
(resource types, case, existence).
"""
if destination is None:
raise ValueError("Destination path must be provided.")

source_is_lit = source.startswith("lit://")
dest_is_lit = destination.startswith("lit://")

if source_is_lit:
source = _canonicalize_lit_resource_type(source)
if dest_is_lit:
destination = _canonicalize_lit_resource_type(destination)

if source_is_lit and dest_is_lit:
raise ValueError("Cannot copy between two remote URLs. One path must be local.")

if not source_is_lit and not dest_is_lit:
raise ValueError("At least one path must be a lit://")

# Every resource type is a path in the teamspace drive, passed through
# for the server to resolve. This validates the URL shape up front.
parse_lit_url(source if source_is_lit else destination)

return Filesystem().copy(
source=source,
destination=destination,
Expand Down
16 changes: 15 additions & 1 deletion python/lightning_sdk/cli/cp/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,23 @@ def _safe_remote_completions(incomplete: str) -> list[CompletionItem]:
def _complete_remote_path(incomplete: str) -> list[CompletionItem]:
parts = incomplete[len(_LIT_PREFIX) :].split("/")

if parts[0] == "" and len(parts) > 1:
# Relative form lit:///<path> — complete from the current teamspace's drive.
from lightning_sdk.utils.resolve import _resolve_teamspace

teamspace = _resolve_teamspace(teamspace=None, org=None, user=None)
if teamspace is None:
return []
parent = "/".join(parts[1:-1])
entries = FilesystemApi().list_files(teamspace.id, parent, recursive=False)
return _complete_tree_entries(incomplete, entries)

if len(parts) == 1:
owner_names = _accessible_teamspaces()
return _complete_values(incomplete, (f"{_LIT_PREFIX}{owner}/" for owner in owner_names))
return _complete_values(
incomplete,
(f"{_LIT_PREFIX}{owner}/" for owner in owner_names),
) + _complete_values(incomplete, [f"{_LIT_PREFIX}/"])

owner = parts[0]
if len(parts) == 2:
Expand Down
12 changes: 8 additions & 4 deletions python/lightning_sdk/cli/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,19 +206,21 @@ def dataset() -> None:
def cp(_ctx: click.Context) -> None:
"""Copy between local, Studios, Drive.

Every lit:// URL must include a resource root right after the teamspace that
tells Lightning where to send the file. There is no default - a URL without
a root will fail.
Every lit:// URL must include a resource root at the start of its drive path
that tells Lightning where to send the file. There is no default - a URL
without a root will fail.

URL formats:
Studios: lit://<owner>/<teamspace>/studios/<studio-name>/<path>
Teamspace drives: lit://<owner>/<teamspace>/uploads/<path>
Current teamspace (relative): lit:///<resource-root>/<path>

Examples:
lightning cp source.txt lit://<owner>/<my-teamspace>/studios/<my-studio>/destination.txt
lightning cp -r source_folder/ lit://<owner>/<my-teamspace>/studios/<my-studio>/destination_folder/
lightning cp source.txt lit://<owner>/<my-teamspace>/uploads/destination.txt
lightning cp -r source_folder/ lit://<owner>/<my-teamspace>/uploads/destination_folder/
lightning cp source.txt lit:///uploads/destination.txt
lightning cp -r source_folder/ lit:///studios/<my-studio>/destination_folder/
"""


Expand All @@ -239,9 +241,11 @@ def edit(_ctx: click.Context) -> None:
URL formats:
Studios: lit://<owner>/<teamspace>/studios/<studio-name>/<path>
Teamspace drives: lit://<owner>/<teamspace>/uploads/<path>
Current teamspace (relative): lit:///<resource-root>/<path>

Examples:
lightning edit lit://<owner>/<my-teamspace>/studios/<my-studio>/notes.txt
lightning edit lit:///studios/<my-studio>/notes.txt
lightning edit lit://<owner>/<my-teamspace>/uploads/config.yaml --editor "code -w"
"""

Expand Down
32 changes: 32 additions & 0 deletions python/lightning_sdk/cli/legacy_redirects.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,35 @@ def _deprecated_get_help(ctx: click.Context) -> str:

def build_hidden_alias_group(name: str, target_group: click.Group) -> HiddenAliasGroup:
return HiddenAliasGroup(name=name, target_group=target_group)


def mark_deprecated_command(cmd: click.Command, replacement: str, detail: str | None = None) -> click.Command:
"""Mark a still-functional command as deprecated in favor of ``replacement``.

Unlike :class:`DeprecatedForwardCommand`, the command keeps its own behavior; a
warning is shown on every invocation and prepended to its help text. ``detail``
is appended to the warning — use it when switching commands takes more than a
rename (e.g. a different URL format).
"""
dynamic_cmd = cast(Any, cmd)
if getattr(dynamic_cmd, "_lightning_deprecation_wrapped", False):
return cmd

original_invoke = cmd.invoke
original_get_help = cmd.get_help

def _warning(ctx: click.Context) -> str:
message = _format_deprecation_warning(ctx.command_path, replacement)
return f"{message} {detail}" if detail else message

def _deprecated_invoke(ctx: click.Context) -> object:
click.secho(_warning(ctx), fg="yellow", err=True)
return original_invoke(ctx)

def _deprecated_get_help(ctx: click.Context) -> str:
return f"{_warning(ctx)}\n\n{original_get_help(ctx)}"

dynamic_cmd.invoke = _deprecated_invoke
dynamic_cmd.get_help = _deprecated_get_help
dynamic_cmd._lightning_deprecation_wrapped = True
return cmd
12 changes: 6 additions & 6 deletions python/lightning_sdk/cli/ls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
from lightning_sdk.api.filesystem_api import FilesystemApi
from lightning_sdk.api.utils import _tree_path_info
from lightning_sdk.cli.cp.completion import complete_remote_path
from lightning_sdk.cli.utils.filesystem import resolve_teamspace
from lightning_sdk.cli.utils.filesystem import resolve_lit_url
from lightning_sdk.cli.utils.json_output import echo_json
from lightning_sdk.cli.utils.logging import LightningCommand
from lightning_sdk.utils.filesystem import parse_lit_url


@click.command("ls", cls=LightningCommand)
Expand All @@ -18,12 +17,14 @@
def ls(path: str, recursive: bool = False, as_json: bool = False) -> None:
"""List contents of a teamspace drive directory.

PATH: Drive path in the format lit://<owner>/<teamspace>/<directory-path>.
PATH: Drive path in the format lit://<owner>/<teamspace>/<directory-path>,
or lit:///<directory-path> for the current teamspace.
The teamspace root lists the drive's top-level folders (studios, uploads, ...).

Examples:
lightning ls lit://<owner>/<my-teamspace>/
lightning ls lit://<owner>/<my-teamspace>/artifacts/reports
lightning ls lit:///artifacts/reports
lightning ls -r lit://<owner>/<my-teamspace>/artifacts/reports
lightning ls --json lit://<owner>/<my-teamspace>/artifacts/reports

Expand All @@ -35,9 +36,8 @@ def ls_impl(path: str, recursive: bool = False, as_json: bool = False) -> None:
if not path.startswith("lit://"):
raise ValueError("Path must be a drive path starting with 'lit://'.")

path_result = parse_lit_url(path)
remote_path = (path_result["destination"] or "").strip("/")
selected_teamspace = resolve_teamspace(path_result["teamspace"], path_result["owner"])
selected_teamspace, remote_path = resolve_lit_url(path)
remote_path = remote_path.strip("/")

filesystem_api = FilesystemApi()

Expand Down
4 changes: 3 additions & 1 deletion python/lightning_sdk/cli/rm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
def rm(path: str, recursive: bool = False, force: bool = False) -> None:
"""Remove a file or directory from a teamspace drive.

PATH: Drive path to remove, in the format lit://<owner>/<teamspace>/<path>.
PATH: Drive path to remove, in the format lit://<owner>/<teamspace>/<path>,
or lit:///<path> for the current teamspace.

Examples:
lightning rm lit://<owner>/<my-teamspace>/uploads/file.txt
lightning rm lit:///uploads/file.txt
lightning rm -r lit://<owner>/<my-teamspace>/artifacts/reports/

"""
Expand Down
13 changes: 10 additions & 3 deletions python/lightning_sdk/cli/studio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

def register_commands(group: click.Group) -> None:
"""Register studio commands with the given group."""
from lightning_sdk.cli.legacy_redirects import mark_deprecated_command
from lightning_sdk.cli.studio.connect import connect_studio
from lightning_sdk.cli.studio.cp import cp_studio_file
from lightning_sdk.cli.studio.create import create_studio
Expand Down Expand Up @@ -36,7 +37,13 @@ def register_commands(group: click.Group) -> None:
group.add_command(stop_studio)
group.add_command(switch_studio)
group.add_command(connect_studio)
group.add_command(cp_studio_file)
group.add_command(ls_studio)
group.add_command(rm_studio_file)
# The replacements take full drive URLs, not this group's short studio forms, so the
# warning spells out the conversion — a bare command rename could target a different drive.
url_note = (
"Note the URL format differs: use lit://<owner>/<teamspace>/studios/<studio>/<path>, "
"or lit:///studios/<studio>/<path> for the current teamspace."
)
group.add_command(mark_deprecated_command(cp_studio_file, "lightning cp", detail=url_note))
group.add_command(mark_deprecated_command(ls_studio, "lightning ls", detail=url_note))
group.add_command(mark_deprecated_command(rm_studio_file, "lightning rm", detail=url_note))
group.add_command(open_studio, name="open")
14 changes: 9 additions & 5 deletions python/lightning_sdk/cli/studio/cp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from rich.console import Console

from lightning_sdk.api.utils import _get_cloud_url
from lightning_sdk.cli.utils.filesystem import parse_studio_path, resolve_studio
from lightning_sdk.cli.studio.paths import parse_studio_path, resolve_studio
from lightning_sdk.cli.utils.logging import LightningCommand
from lightning_sdk.filesystem import Filesystem
from lightning_sdk.studio import Studio
Expand All @@ -19,13 +19,16 @@
def cp_studio_file(source: str, destination: str, recursive: bool = False) -> None:
"""Copy a Studio file.

SOURCE: Source file to copy from. For Studio files, use the format lit://<owner>/<my-teamspace>/studios/<my-studio>/<filepath>.
SOURCE: Source file to copy from. For Studio files, use the format
lit://<owner>/<my-teamspace>/studios/<my-studio>/<filepath>, or
lit:///studios/<my-studio>/<filepath> for the current teamspace.

DESTINATION: Destination file to copy to. For Studio files, use the format lit://<owner>/<my-teamspace>/studios/<my-studio>/<filepath>.
DESTINATION: Destination file to copy to. For Studio files, use the same formats.

Example:
lightning studio cp source.txt lit://<owner>/<my-teamspace>/studios/<my-studio>/destination.txt
lightning studio cp -r source_folder/ lit://<owner>/<my-teamspace>/studios/<my-studio>/destination_folder/
lightning studio cp source.txt lit:///studios/<my-studio>/destination.txt
lightning studio cp -r source_folder/ lit:///studios/<my-studio>/destination_folder/

"""
return cp_impl(source=source, destination=destination, recursive=recursive)
Expand Down Expand Up @@ -59,7 +62,8 @@ def _resolve_drive_url(studio_path: str) -> Tuple[Studio, str]:
"""Resolve a studio lit URL to the studio and its fully-qualified drive URL.

Unlike the main ``lightning cp`` URLs, studio paths may omit the owner or
the owner and teamspace, which then resolve from the configured defaults.
the owner and teamspace, which then resolve from the configured defaults;
the relative form (``lit:///studios/...``) resolves the same way.
"""
parsed = parse_studio_path(studio_path)
studio = resolve_studio(parsed["studio"], parsed["teamspace"], parsed["owner"])
Expand Down
7 changes: 4 additions & 3 deletions python/lightning_sdk/cli/studio/ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import rich_click as click

from lightning_sdk.cli.utils.filesystem import parse_studio_path, resolve_studio
from lightning_sdk.cli.studio.paths import parse_studio_path, resolve_studio
from lightning_sdk.cli.utils.logging import LightningCommand


Expand All @@ -11,11 +11,12 @@
def ls_studio(path: str) -> None:
"""List contents of a directory in Studio.

PATH: Studio path in the format
lit://<owner>/<teamspace>/studios/<studio>/<directory-path>
PATH: Studio path in the format lit://<owner>/<teamspace>/studios/<studio>/<directory-path>,
or lit:///studios/<studio>/<directory-path> for the current teamspace.

Example:
lightning studio ls lit://<owner>/<my-teamspace>/studios/<my-studio>/data
lightning studio ls lit:///studios/<my-studio>/data

"""
return ls_impl(path=path)
Expand Down
89 changes: 89 additions & 0 deletions python/lightning_sdk/cli/studio/paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Path parsing and resolution for the deprecated ``lightning studio cp/ls/rm`` commands.

The main drive commands (``lightning cp/ls/rm``) go through
:func:`lightning_sdk.utils.filesystem.parse_lit_url` instead; the parser here
additionally accepts the studio commands' legacy short forms.
"""

from typing import Optional

from lightning_sdk.cli.utils.resource_resolution import join_teamspace_slug
from lightning_sdk.cli.utils.resource_resolution import resolve_studio as resolve_cli_studio
from lightning_sdk.cli.utils.resource_resolution import resolve_teamspace as resolve_cli_teamspace
from lightning_sdk.studio import Studio
from lightning_sdk.utils.filesystem import PathResult


def parse_studio_path(studio_path: str) -> PathResult:
"""Parse a studio ``lit://`` URL into owner, teamspace, studio, and destination.

Accepts ``lit://[owner/][teamspace/]studios/<studio>/<path>``, the short form
``lit://<studio>/<path>``, and the relative form ``lit:///studios/<studio>/<path>``
(a studio in the current teamspace). Omitted owner and teamspace resolve to the
configured defaults.
"""
prefix = "lit://"
has_prefix = studio_path.startswith(prefix)
path_string = studio_path[len(prefix) :] if has_prefix else studio_path
if not path_string:
raise ValueError("Studio path cannot be empty after prefix")

result: PathResult = {"owner": None, "teamspace": None, "studio": None, "destination": None}

relative = path_string.startswith("/")
if relative:
# Relative form: lit:///... targets the current teamspace, which is also
# what the short forms below resolve to when owner/teamspace are omitted.
# Only the lit:/// spelling means that — a bare absolute path is a
# local-path mistake, not a studio path.
path_string = path_string[1:]
if not has_prefix or path_string.startswith("/") or (path_string and not path_string.startswith("studios/")):
raise ValueError(
f"Invalid studio path {studio_path!r}. Expected 'lit://<owner>/<teamspace>/studios/<studio>/<path>' "
"or 'lit:///studios/<studio>/<path>' for the current teamspace."
)
if not path_string:
raise ValueError("Studio path cannot be empty after prefix")

if relative:
# lit:///studios/<studio>/<path> — the leading root has no owner/teamspace
# before it, so the "/studios/" split below would not match it.
path_parts = path_string[len("studios/") :].split("/")

elif "/studios/" in path_string:
prefix_part, suffix_part = path_string.split("/studios/", 1)

# org and teamspace
if prefix_part:
org_ts_components = prefix_part.split("/")
if len(org_ts_components) == 2:
result["owner"], result["teamspace"] = org_ts_components
elif len(org_ts_components) == 1:
result["teamspace"] = org_ts_components[0]
else:
raise ValueError(f"Invalid format: '{prefix_part}'")

# studio and destination
path_parts = suffix_part.split("/")

else:
# studio and destination
path_parts = path_string.split("/")

if not path_parts or not path_parts[0]:
raise ValueError("Invalid: Missing studio name.")

if len(path_parts) == 1:
raise ValueError(
"Invalid: Invalid studio path. To refer to the studio root, add a trailing '/' (e.g., 'lit://<owner>/<my-teamspace>/studios/<my-studio>/')"
)

result["studio"] = path_parts[0]
result["destination"] = "/".join(path_parts[1:])

return result


def resolve_studio(studio_name: Optional[str], teamspace: Optional[str], owner: Optional[str]) -> Studio:
resolved_teamspace = resolve_cli_teamspace(join_teamspace_slug(owner, teamspace))
return resolve_cli_studio(studio_name, resolved_teamspace)
Loading
Loading