cloudscope 0.1.0__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.
- cloudscope/__init__.py +3 -0
- cloudscope/__main__.py +12 -0
- cloudscope/app.py +100 -0
- cloudscope/auth/__init__.py +0 -0
- cloudscope/auth/aws.py +42 -0
- cloudscope/auth/drive_oauth.py +77 -0
- cloudscope/auth/gcp.py +42 -0
- cloudscope/backends/__init__.py +0 -0
- cloudscope/backends/base.py +98 -0
- cloudscope/backends/drive.py +568 -0
- cloudscope/backends/gcs.py +270 -0
- cloudscope/backends/registry.py +23 -0
- cloudscope/backends/s3.py +281 -0
- cloudscope/config.py +70 -0
- cloudscope/models/__init__.py +0 -0
- cloudscope/models/cloud_file.py +48 -0
- cloudscope/models/sync_state.py +87 -0
- cloudscope/models/transfer.py +46 -0
- cloudscope/sync/__init__.py +0 -0
- cloudscope/sync/differ.py +165 -0
- cloudscope/sync/engine.py +214 -0
- cloudscope/sync/plan.py +46 -0
- cloudscope/sync/resolver.py +64 -0
- cloudscope/sync/state.py +140 -0
- cloudscope/transfer/__init__.py +0 -0
- cloudscope/transfer/manager.py +150 -0
- cloudscope/transfer/progress.py +20 -0
- cloudscope/tui/__init__.py +0 -0
- cloudscope/tui/commands.py +47 -0
- cloudscope/tui/modals/__init__.py +0 -0
- cloudscope/tui/modals/confirm_dialog.py +93 -0
- cloudscope/tui/modals/download_dialog.py +111 -0
- cloudscope/tui/modals/new_folder.py +96 -0
- cloudscope/tui/modals/sync_dialog.py +142 -0
- cloudscope/tui/modals/upload_dialog.py +109 -0
- cloudscope/tui/screens/__init__.py +0 -0
- cloudscope/tui/screens/auth_setup.py +154 -0
- cloudscope/tui/screens/browse.py +282 -0
- cloudscope/tui/screens/settings.py +222 -0
- cloudscope/tui/screens/sync_config.py +245 -0
- cloudscope/tui/styles/cloudscope.tcss +336 -0
- cloudscope/tui/widgets/__init__.py +0 -0
- cloudscope/tui/widgets/app_footer.py +46 -0
- cloudscope/tui/widgets/breadcrumb.py +39 -0
- cloudscope/tui/widgets/cloud_tree.py +146 -0
- cloudscope/tui/widgets/file_table.py +113 -0
- cloudscope/tui/widgets/preview_panel.py +59 -0
- cloudscope/tui/widgets/status_bar.py +27 -0
- cloudscope/tui/widgets/transfer_panel.py +54 -0
- cloudscope-0.1.0.dist-info/METADATA +22 -0
- cloudscope-0.1.0.dist-info/RECORD +53 -0
- cloudscope-0.1.0.dist-info/WHEEL +4 -0
- cloudscope-0.1.0.dist-info/entry_points.txt +2 -0
cloudscope/__init__.py
ADDED
cloudscope/__main__.py
ADDED
cloudscope/app.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Main CloudScope application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
|
|
10
|
+
from cloudscope.backends.registry import available_backends, get_backend
|
|
11
|
+
from cloudscope.backends import s3 as _s3_reg # noqa: F401 — triggers register_backend
|
|
12
|
+
from cloudscope.backends import gcs as _gcs_reg # noqa: F401
|
|
13
|
+
from cloudscope.backends import drive as _drive_reg # noqa: F401
|
|
14
|
+
from cloudscope.config import AppConfig
|
|
15
|
+
from cloudscope.tui.commands import CloudScopeCommands
|
|
16
|
+
from cloudscope.tui.screens.auth_setup import AuthSetupScreen
|
|
17
|
+
from cloudscope.tui.screens.browse import BrowseScreen
|
|
18
|
+
from cloudscope.tui.screens.settings import SettingsScreen
|
|
19
|
+
from cloudscope.tui.screens.sync_config import SyncConfigScreen
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CloudScopeApp(App[None]):
|
|
23
|
+
"""CloudScope — browse and sync cloud storage from your terminal."""
|
|
24
|
+
|
|
25
|
+
TITLE = "CloudScope"
|
|
26
|
+
CSS_PATH = Path(__file__).parent / "tui" / "styles" / "cloudscope.tcss"
|
|
27
|
+
COMMANDS = {CloudScopeCommands}
|
|
28
|
+
|
|
29
|
+
BINDINGS = [
|
|
30
|
+
Binding("q", "quit", "Quit", show=False, priority=True),
|
|
31
|
+
Binding("1", "switch_backend('s3')", "S3", show=False, priority=True),
|
|
32
|
+
Binding("2", "switch_backend('gcs')", "GCS", show=False, priority=True),
|
|
33
|
+
Binding("3", "switch_backend('drive')", "Drive", show=False, priority=True),
|
|
34
|
+
Binding("s", "open_sync", "Sync", show=False, priority=True),
|
|
35
|
+
Binding("comma", "open_settings", "Settings", show=False, priority=True),
|
|
36
|
+
Binding("a", "open_auth", "Auth", show=False, priority=True),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._config = AppConfig.load()
|
|
42
|
+
self._current_backend = None
|
|
43
|
+
self._browse_screen: BrowseScreen | None = None
|
|
44
|
+
|
|
45
|
+
def on_mount(self) -> None:
|
|
46
|
+
"""Initialize the app — connect to default backend and show browse screen."""
|
|
47
|
+
backend = self._create_backend(self._config.default_backend)
|
|
48
|
+
self._current_backend = backend
|
|
49
|
+
self._browse_screen = BrowseScreen(backend=backend)
|
|
50
|
+
self.push_screen(self._browse_screen)
|
|
51
|
+
|
|
52
|
+
def _create_backend(self, backend_type: str):
|
|
53
|
+
"""Create a backend instance from config."""
|
|
54
|
+
backend_config = self._config.backends.get(backend_type)
|
|
55
|
+
profile = backend_config.profile if backend_config else None
|
|
56
|
+
extra = backend_config.extra if backend_config else {}
|
|
57
|
+
|
|
58
|
+
kwargs = {}
|
|
59
|
+
if backend_type == "s3":
|
|
60
|
+
if profile:
|
|
61
|
+
kwargs["profile"] = profile
|
|
62
|
+
if extra.get("region"):
|
|
63
|
+
kwargs["region"] = extra["region"]
|
|
64
|
+
elif backend_type == "gcs":
|
|
65
|
+
if extra.get("project"):
|
|
66
|
+
kwargs["project"] = extra["project"]
|
|
67
|
+
if extra.get("service_account_key"):
|
|
68
|
+
kwargs["service_account_key"] = extra["service_account_key"]
|
|
69
|
+
elif backend_type == "drive":
|
|
70
|
+
if extra.get("client_secrets_path"):
|
|
71
|
+
kwargs["client_secrets_path"] = extra["client_secrets_path"]
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return get_backend(backend_type, **kwargs)
|
|
75
|
+
except ValueError:
|
|
76
|
+
self.notify(
|
|
77
|
+
f"Backend '{backend_type}' not available. Available: {available_backends()}",
|
|
78
|
+
severity="error",
|
|
79
|
+
)
|
|
80
|
+
return get_backend("s3")
|
|
81
|
+
|
|
82
|
+
def action_switch_backend(self, backend_type: str) -> None:
|
|
83
|
+
"""Switch to a different cloud backend."""
|
|
84
|
+
backend = self._create_backend(backend_type)
|
|
85
|
+
self._current_backend = backend
|
|
86
|
+
if self._browse_screen:
|
|
87
|
+
self._browse_screen.set_backend(backend)
|
|
88
|
+
self.notify(f"Switched to {backend.display_name}")
|
|
89
|
+
|
|
90
|
+
def action_open_sync(self) -> None:
|
|
91
|
+
"""Open the sync configuration screen."""
|
|
92
|
+
self.push_screen(SyncConfigScreen(backend=self._current_backend))
|
|
93
|
+
|
|
94
|
+
def action_open_settings(self) -> None:
|
|
95
|
+
"""Open the settings screen."""
|
|
96
|
+
self.push_screen(SettingsScreen())
|
|
97
|
+
|
|
98
|
+
def action_open_auth(self) -> None:
|
|
99
|
+
"""Open the auth setup screen."""
|
|
100
|
+
self.push_screen(AuthSetupScreen())
|
|
File without changes
|
cloudscope/auth/aws.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""AWS credential handling — profile discovery and session creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from configparser import ConfigParser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import boto3
|
|
11
|
+
import botocore.session
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def list_aws_profiles() -> list[str]:
|
|
15
|
+
"""Return available AWS profile names from ~/.aws/credentials and ~/.aws/config."""
|
|
16
|
+
profiles: set[str] = set()
|
|
17
|
+
creds_file = Path.home() / ".aws" / "credentials"
|
|
18
|
+
config_file = Path.home() / ".aws" / "config"
|
|
19
|
+
|
|
20
|
+
for filepath in (creds_file, config_file):
|
|
21
|
+
if filepath.exists():
|
|
22
|
+
parser = ConfigParser()
|
|
23
|
+
parser.read(filepath)
|
|
24
|
+
for section in parser.sections():
|
|
25
|
+
name = section.removeprefix("profile ").strip()
|
|
26
|
+
profiles.add(name)
|
|
27
|
+
|
|
28
|
+
if not profiles:
|
|
29
|
+
profiles.add("default")
|
|
30
|
+
return sorted(profiles)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_s3_client(profile: str | None = None, region: str | None = None) -> Any:
|
|
34
|
+
"""Create a boto3 S3 client with the given profile."""
|
|
35
|
+
session = boto3.Session(profile_name=profile, region_name=region)
|
|
36
|
+
return session.client("s3")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def create_s3_client_async(
|
|
40
|
+
profile: str | None = None, region: str | None = None
|
|
41
|
+
) -> Any:
|
|
42
|
+
return await asyncio.to_thread(create_s3_client, profile, region)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Google Drive OAuth2 authentication flow and token management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from google.auth.transport.requests import Request
|
|
11
|
+
from google.oauth2.credentials import Credentials
|
|
12
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
13
|
+
from platformdirs import user_config_dir
|
|
14
|
+
|
|
15
|
+
SCOPES = [
|
|
16
|
+
"https://www.googleapis.com/auth/drive",
|
|
17
|
+
"https://www.googleapis.com/auth/drive.metadata.readonly",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
CONFIG_DIR = Path(user_config_dir("cloudscope", ensure_exists=True))
|
|
21
|
+
TOKEN_FILE = CONFIG_DIR / "drive_token.json"
|
|
22
|
+
CLIENT_SECRETS_FILE = CONFIG_DIR / "drive_client_secrets.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_drive_credentials(
|
|
26
|
+
client_secrets_path: str | None = None,
|
|
27
|
+
) -> Credentials:
|
|
28
|
+
"""Get or refresh Google Drive credentials.
|
|
29
|
+
|
|
30
|
+
On first run, opens a browser for OAuth consent.
|
|
31
|
+
Subsequent runs use the stored refresh token.
|
|
32
|
+
"""
|
|
33
|
+
credentials: Credentials | None = None
|
|
34
|
+
|
|
35
|
+
# Try to load existing token
|
|
36
|
+
if TOKEN_FILE.exists():
|
|
37
|
+
credentials = Credentials.from_authorized_user_file(str(TOKEN_FILE), SCOPES)
|
|
38
|
+
|
|
39
|
+
# Refresh or obtain new credentials
|
|
40
|
+
if credentials and credentials.expired and credentials.refresh_token:
|
|
41
|
+
credentials.refresh(Request())
|
|
42
|
+
_save_token(credentials)
|
|
43
|
+
elif not credentials or not credentials.valid:
|
|
44
|
+
secrets_path = client_secrets_path or str(CLIENT_SECRETS_FILE)
|
|
45
|
+
if not Path(secrets_path).exists():
|
|
46
|
+
raise FileNotFoundError(
|
|
47
|
+
f"Google Drive client secrets not found at {secrets_path}. "
|
|
48
|
+
f"Download OAuth credentials from Google Cloud Console and save to {CLIENT_SECRETS_FILE}"
|
|
49
|
+
)
|
|
50
|
+
flow = InstalledAppFlow.from_client_secrets_file(secrets_path, SCOPES)
|
|
51
|
+
credentials = flow.run_local_server(port=0)
|
|
52
|
+
_save_token(credentials)
|
|
53
|
+
|
|
54
|
+
return credentials
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def get_drive_credentials_async(
|
|
58
|
+
client_secrets_path: str | None = None,
|
|
59
|
+
) -> Credentials:
|
|
60
|
+
return await asyncio.to_thread(get_drive_credentials, client_secrets_path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _save_token(credentials: Credentials) -> None:
|
|
64
|
+
"""Save credentials to the token file with restricted permissions."""
|
|
65
|
+
TOKEN_FILE.write_text(credentials.to_json())
|
|
66
|
+
TOKEN_FILE.chmod(0o600)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def has_drive_credentials() -> bool:
|
|
70
|
+
"""Check if Drive credentials exist (may be expired)."""
|
|
71
|
+
return TOKEN_FILE.exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear_drive_credentials() -> None:
|
|
75
|
+
"""Remove stored Drive credentials."""
|
|
76
|
+
if TOKEN_FILE.exists():
|
|
77
|
+
TOKEN_FILE.unlink()
|
cloudscope/auth/gcp.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""GCP credential handling — Application Default Credentials and service accounts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import google.auth
|
|
10
|
+
from google.cloud import storage
|
|
11
|
+
from google.oauth2 import service_account
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_gcs_client(
|
|
15
|
+
project: str | None = None,
|
|
16
|
+
service_account_key: str | None = None,
|
|
17
|
+
) -> storage.Client:
|
|
18
|
+
"""Create a GCS client using ADC or a service account key file."""
|
|
19
|
+
if service_account_key:
|
|
20
|
+
key_path = Path(service_account_key)
|
|
21
|
+
if not key_path.exists():
|
|
22
|
+
raise FileNotFoundError(f"Service account key not found: {key_path}")
|
|
23
|
+
credentials = service_account.Credentials.from_service_account_file(
|
|
24
|
+
str(key_path),
|
|
25
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
|
26
|
+
)
|
|
27
|
+
return storage.Client(project=project, credentials=credentials)
|
|
28
|
+
|
|
29
|
+
# Fall back to Application Default Credentials
|
|
30
|
+
credentials, detected_project = google.auth.default(
|
|
31
|
+
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
|
32
|
+
)
|
|
33
|
+
return storage.Client(
|
|
34
|
+
project=project or detected_project, credentials=credentials
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def create_gcs_client_async(
|
|
39
|
+
project: str | None = None,
|
|
40
|
+
service_account_key: str | None = None,
|
|
41
|
+
) -> storage.Client:
|
|
42
|
+
return await asyncio.to_thread(create_gcs_client, project, service_account_key)
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CloudBackend Protocol — the central abstraction all backends implement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from cloudscope.models.cloud_file import CloudFile
|
|
9
|
+
|
|
10
|
+
ProgressCallback = Callable[[int, int], None] # (bytes_transferred, total_bytes)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CloudScopeError(Exception):
|
|
14
|
+
"""Base exception for all cloudscope errors."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthenticationError(CloudScopeError):
|
|
18
|
+
"""Credentials are invalid or expired."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PermissionError(CloudScopeError): # noqa: A001
|
|
22
|
+
"""Insufficient permissions for the requested operation."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(CloudScopeError):
|
|
26
|
+
"""The requested file, folder, or container does not exist."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class QuotaError(CloudScopeError):
|
|
30
|
+
"""Rate limit or storage quota exceeded."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class NetworkError(CloudScopeError):
|
|
34
|
+
"""Connectivity failure."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class CloudBackend(Protocol):
|
|
39
|
+
"""Unified interface for cloud storage backends."""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def backend_type(self) -> str: ...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def display_name(self) -> str: ...
|
|
46
|
+
|
|
47
|
+
# --- Connection ---
|
|
48
|
+
async def connect(self) -> None: ...
|
|
49
|
+
|
|
50
|
+
async def disconnect(self) -> None: ...
|
|
51
|
+
|
|
52
|
+
async def is_connected(self) -> bool: ...
|
|
53
|
+
|
|
54
|
+
# --- Container listing ---
|
|
55
|
+
async def list_containers(self) -> list[str]: ...
|
|
56
|
+
|
|
57
|
+
# --- Browsing ---
|
|
58
|
+
async def list_files(
|
|
59
|
+
self,
|
|
60
|
+
container: str,
|
|
61
|
+
prefix: str = "",
|
|
62
|
+
recursive: bool = False,
|
|
63
|
+
) -> list[CloudFile]: ...
|
|
64
|
+
|
|
65
|
+
async def stat(self, container: str, path: str) -> CloudFile: ...
|
|
66
|
+
|
|
67
|
+
async def exists(self, container: str, path: str) -> bool: ...
|
|
68
|
+
|
|
69
|
+
# --- Transfer ---
|
|
70
|
+
async def download(
|
|
71
|
+
self,
|
|
72
|
+
container: str,
|
|
73
|
+
remote_path: str,
|
|
74
|
+
local_path: str,
|
|
75
|
+
progress_callback: ProgressCallback | None = None,
|
|
76
|
+
) -> None: ...
|
|
77
|
+
|
|
78
|
+
async def upload(
|
|
79
|
+
self,
|
|
80
|
+
container: str,
|
|
81
|
+
local_path: str,
|
|
82
|
+
remote_path: str,
|
|
83
|
+
progress_callback: ProgressCallback | None = None,
|
|
84
|
+
) -> CloudFile: ...
|
|
85
|
+
|
|
86
|
+
# --- Mutation ---
|
|
87
|
+
async def delete(self, container: str, path: str) -> None: ...
|
|
88
|
+
|
|
89
|
+
async def create_folder(self, container: str, path: str) -> CloudFile: ...
|
|
90
|
+
|
|
91
|
+
async def move(self, container: str, src: str, dst: str) -> CloudFile: ...
|
|
92
|
+
|
|
93
|
+
async def copy(self, container: str, src: str, dst: str) -> CloudFile: ...
|
|
94
|
+
|
|
95
|
+
# --- Sync support ---
|
|
96
|
+
async def list_files_recursive(
|
|
97
|
+
self, container: str, prefix: str = ""
|
|
98
|
+
) -> AsyncIterator[CloudFile]: ...
|