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.
Files changed (53) hide show
  1. cloudscope/__init__.py +3 -0
  2. cloudscope/__main__.py +12 -0
  3. cloudscope/app.py +100 -0
  4. cloudscope/auth/__init__.py +0 -0
  5. cloudscope/auth/aws.py +42 -0
  6. cloudscope/auth/drive_oauth.py +77 -0
  7. cloudscope/auth/gcp.py +42 -0
  8. cloudscope/backends/__init__.py +0 -0
  9. cloudscope/backends/base.py +98 -0
  10. cloudscope/backends/drive.py +568 -0
  11. cloudscope/backends/gcs.py +270 -0
  12. cloudscope/backends/registry.py +23 -0
  13. cloudscope/backends/s3.py +281 -0
  14. cloudscope/config.py +70 -0
  15. cloudscope/models/__init__.py +0 -0
  16. cloudscope/models/cloud_file.py +48 -0
  17. cloudscope/models/sync_state.py +87 -0
  18. cloudscope/models/transfer.py +46 -0
  19. cloudscope/sync/__init__.py +0 -0
  20. cloudscope/sync/differ.py +165 -0
  21. cloudscope/sync/engine.py +214 -0
  22. cloudscope/sync/plan.py +46 -0
  23. cloudscope/sync/resolver.py +64 -0
  24. cloudscope/sync/state.py +140 -0
  25. cloudscope/transfer/__init__.py +0 -0
  26. cloudscope/transfer/manager.py +150 -0
  27. cloudscope/transfer/progress.py +20 -0
  28. cloudscope/tui/__init__.py +0 -0
  29. cloudscope/tui/commands.py +47 -0
  30. cloudscope/tui/modals/__init__.py +0 -0
  31. cloudscope/tui/modals/confirm_dialog.py +93 -0
  32. cloudscope/tui/modals/download_dialog.py +111 -0
  33. cloudscope/tui/modals/new_folder.py +96 -0
  34. cloudscope/tui/modals/sync_dialog.py +142 -0
  35. cloudscope/tui/modals/upload_dialog.py +109 -0
  36. cloudscope/tui/screens/__init__.py +0 -0
  37. cloudscope/tui/screens/auth_setup.py +154 -0
  38. cloudscope/tui/screens/browse.py +282 -0
  39. cloudscope/tui/screens/settings.py +222 -0
  40. cloudscope/tui/screens/sync_config.py +245 -0
  41. cloudscope/tui/styles/cloudscope.tcss +336 -0
  42. cloudscope/tui/widgets/__init__.py +0 -0
  43. cloudscope/tui/widgets/app_footer.py +46 -0
  44. cloudscope/tui/widgets/breadcrumb.py +39 -0
  45. cloudscope/tui/widgets/cloud_tree.py +146 -0
  46. cloudscope/tui/widgets/file_table.py +113 -0
  47. cloudscope/tui/widgets/preview_panel.py +59 -0
  48. cloudscope/tui/widgets/status_bar.py +27 -0
  49. cloudscope/tui/widgets/transfer_panel.py +54 -0
  50. cloudscope-0.1.0.dist-info/METADATA +22 -0
  51. cloudscope-0.1.0.dist-info/RECORD +53 -0
  52. cloudscope-0.1.0.dist-info/WHEEL +4 -0
  53. cloudscope-0.1.0.dist-info/entry_points.txt +2 -0
cloudscope/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CloudScope — TUI for browsing and syncing files across S3, GCS, and Google Drive."""
2
+
3
+ __version__ = "0.1.0"
cloudscope/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Entry point for `python -m cloudscope` and the `cloudscope` CLI command."""
2
+
3
+ from cloudscope.app import CloudScopeApp
4
+
5
+
6
+ def main() -> None:
7
+ app = CloudScopeApp()
8
+ app.run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
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]: ...