dedrive 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.
dedrive/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ """Google Drive Deduplication Tool - package API."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from dedrive.drive import (
6
+ SCOPES,
7
+ setup_logging,
8
+ authenticate,
9
+ create_oauth_flow,
10
+ run_oauth_callback_server,
11
+ save_token,
12
+ load_existing_token,
13
+ get_user_info,
14
+ fetch_with_retry,
15
+ fetch_all_files,
16
+ build_lookups,
17
+ get_path,
18
+ )
19
+ from dedrive.dedup import (
20
+ filter_by_path,
21
+ filter_excluded_paths,
22
+ find_duplicates,
23
+ write_csv,
24
+ calculate_savings,
25
+ format_size,
26
+ )
27
+ from dedrive.config import (
28
+ get_exclude_paths,
29
+ get_credentials_path,
30
+ get_token_path,
31
+ get_output_dir,
32
+ get_dupes_folder,
33
+ get_batch_size,
34
+ get_max_preview_size,
35
+ set_active_profile,
36
+ set_active_profile_from_email,
37
+ create_default_config,
38
+ print_config,
39
+ )
40
+ from dedrive.profiles import (
41
+ init_profile,
42
+ list_profiles,
43
+ delete_profile_token,
44
+ )
45
+
46
+ __all__ = [
47
+ "__version__",
48
+ # drive
49
+ "SCOPES",
50
+ "setup_logging",
51
+ "authenticate",
52
+ "create_oauth_flow",
53
+ "run_oauth_callback_server",
54
+ "save_token",
55
+ "load_existing_token",
56
+ "get_user_info",
57
+ "fetch_with_retry",
58
+ "fetch_all_files",
59
+ "build_lookups",
60
+ "get_path",
61
+ # dedup
62
+ "filter_by_path",
63
+ "filter_excluded_paths",
64
+ "find_duplicates",
65
+ "write_csv",
66
+ "calculate_savings",
67
+ "format_size",
68
+ # config
69
+ "get_exclude_paths",
70
+ "get_credentials_path",
71
+ "get_token_path",
72
+ "get_output_dir",
73
+ "get_dupes_folder",
74
+ "get_batch_size",
75
+ "get_max_preview_size",
76
+ "set_active_profile",
77
+ "set_active_profile_from_email",
78
+ "create_default_config",
79
+ "print_config",
80
+ # profiles
81
+ "init_profile",
82
+ "list_profiles",
83
+ "delete_profile_token",
84
+ ]
dedrive/cli.py ADDED
@@ -0,0 +1,259 @@
1
+ """CLI entry point for dedrive."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ import webbrowser
7
+
8
+ from dedrive.config import (
9
+ get_credentials_path,
10
+ get_token_path,
11
+ set_active_profile,
12
+ set_active_profile_from_email,
13
+ )
14
+ from dedrive.drive import (
15
+ create_oauth_flow,
16
+ run_oauth_callback_server,
17
+ save_token,
18
+ load_existing_token,
19
+ get_user_info,
20
+ setup_logging,
21
+ authenticate,
22
+ )
23
+ from dedrive.profiles import (
24
+ list_profiles,
25
+ get_profile_token_path,
26
+ delete_profile_token,
27
+ PROFILES_DIR,
28
+ )
29
+
30
+
31
+ def cmd_login(args):
32
+ """Handle the login subcommand."""
33
+ credentials_path = get_credentials_path()
34
+
35
+ try:
36
+ auth_url, flow, port = create_oauth_flow(credentials_path)
37
+ except FileNotFoundError as e:
38
+ print(f"Error: {e}")
39
+ print()
40
+ print("To fix this:")
41
+ print(" 1. Go to https://console.cloud.google.com/apis/credentials")
42
+ print(" 2. Create a project (if you haven't already)")
43
+ print(" 3. Enable the Google Drive API")
44
+ print(" 4. Create OAuth 2.0 Client ID (choose 'Desktop app')")
45
+ print(" 5. Download the JSON file")
46
+ print(f" 6. Save it as: {credentials_path}")
47
+ print(f" Or save it to: {PROFILES_DIR / 'credentials.json'}")
48
+ sys.exit(1)
49
+
50
+ print(f"Opening browser for Google sign-in...")
51
+ print(f"If the browser doesn't open, visit: {auth_url}")
52
+ webbrowser.open(auth_url)
53
+
54
+ print("Waiting for authentication...")
55
+ try:
56
+ creds = run_oauth_callback_server(flow, port)
57
+ except TimeoutError:
58
+ print("Error: Authentication timed out.")
59
+ sys.exit(1)
60
+ except Exception as e:
61
+ print(f"Error: Authentication failed: {e}")
62
+ sys.exit(1)
63
+
64
+ # Build service to get user info
65
+ from googleapiclient.discovery import build
66
+ service = build("drive", "v3", credentials=creds)
67
+ user_info = get_user_info(service)
68
+ email = user_info["email"]
69
+ name = user_info["name"]
70
+
71
+ # Create/activate profile based on email
72
+ profile_name = set_active_profile_from_email(email)
73
+
74
+ # Save token to profile
75
+ token_path = get_token_path()
76
+ save_token(creds, token_path)
77
+
78
+ display_name = f"{name} ({email})" if name else email
79
+ print(f"Logged in as {display_name}")
80
+ print(f"Profile: {profile_name}")
81
+ print(f"Token saved to: {token_path}")
82
+
83
+
84
+ def cmd_logout(args):
85
+ """Handle the logout subcommand."""
86
+ profile = args.profile
87
+
88
+ if not profile:
89
+ # Auto-detect: find profiles with tokens
90
+ logged_in = []
91
+ for name in list_profiles():
92
+ token_path = get_profile_token_path(name)
93
+ if token_path.exists():
94
+ logged_in.append(name)
95
+
96
+ if not logged_in:
97
+ print("No logged-in profiles found.")
98
+ sys.exit(0)
99
+ elif len(logged_in) == 1:
100
+ profile = logged_in[0]
101
+ else:
102
+ print("Multiple logged-in profiles found. Specify one with --profile:")
103
+ for name in logged_in:
104
+ print(f" dedrive logout --profile {name}")
105
+ sys.exit(1)
106
+
107
+ deleted = delete_profile_token(profile)
108
+ if deleted:
109
+ print(f"Logged out from profile: {profile}")
110
+ else:
111
+ print(f"Profile '{profile}' was not logged in.")
112
+
113
+
114
+ def cmd_list_profiles(args):
115
+ """Handle --list-profiles."""
116
+ profiles = list_profiles()
117
+ if not profiles:
118
+ print("No profiles found. Run 'dedrive login' to create one.")
119
+ return
120
+
121
+ print("Profiles:")
122
+ for name in profiles:
123
+ token_path = get_profile_token_path(name)
124
+ status = " (logged in)" if token_path.exists() else ""
125
+ print(f" {name}{status}")
126
+
127
+
128
+ def cmd_ui(args):
129
+ """Handle the default command (launch Gradio UI)."""
130
+ if not args.profile:
131
+ profiles = list_profiles()
132
+ if not profiles:
133
+ print("No profiles found. Run 'dedrive login' first.")
134
+ sys.exit(1)
135
+
136
+ logged_in = [
137
+ name for name in profiles
138
+ if get_profile_token_path(name).exists()
139
+ ]
140
+
141
+ if len(logged_in) == 1:
142
+ args.profile = logged_in[0]
143
+ print(f"Using profile: {args.profile}")
144
+ else:
145
+ display = logged_in if logged_in else profiles
146
+ print("Select a profile:")
147
+ for i, name in enumerate(display, 1):
148
+ status = " (logged in)" if name in logged_in else ""
149
+ print(f" {i}) {name}{status}")
150
+ print()
151
+ try:
152
+ choice = input("Enter number: ").strip()
153
+ idx = int(choice) - 1
154
+ if 0 <= idx < len(display):
155
+ args.profile = display[idx]
156
+ else:
157
+ print("Invalid selection.")
158
+ sys.exit(1)
159
+ except (ValueError, EOFError, KeyboardInterrupt):
160
+ print()
161
+ sys.exit(1)
162
+
163
+ set_active_profile(args.profile)
164
+
165
+ setup_logging(verbose=args.verbose, log_file=args.log_file)
166
+
167
+ if args.validate:
168
+ logger = logging.getLogger(__name__)
169
+ credentials_path = get_credentials_path()
170
+ try:
171
+ from googleapiclient.discovery import build
172
+ creds = authenticate(credentials_path)
173
+ service = build("drive", "v3", credentials=creds)
174
+ user_info = get_user_info(service)
175
+ logger.info(f"Credentials valid. Connected as: {user_info['name']} <{user_info['email']}>")
176
+ sys.exit(0)
177
+ except Exception as e:
178
+ logger.error(f"Credential validation failed: {e}")
179
+ sys.exit(1)
180
+
181
+ from dedrive.ui import create_ui
182
+ from dedrive.config import get_output_dir
183
+ app = create_ui()
184
+ app.launch(share=args.share, server_port=args.port, allowed_paths=[str(get_output_dir())])
185
+
186
+
187
+ def main():
188
+ """CLI entry point."""
189
+ parser = argparse.ArgumentParser(
190
+ prog="dedrive",
191
+ description="Google Drive Deduplication Manager",
192
+ )
193
+ parser.add_argument(
194
+ "--profile", "-P",
195
+ help="Use a named profile",
196
+ )
197
+ parser.add_argument(
198
+ "--list-profiles",
199
+ action="store_true",
200
+ help="List available profiles and exit",
201
+ )
202
+
203
+ subparsers = parser.add_subparsers(dest="command")
204
+
205
+ # login subcommand
206
+ login_parser = subparsers.add_parser("login", help="Authenticate with Google (opens browser)")
207
+ login_parser.add_argument("--profile", "-P", default=argparse.SUPPRESS, help="Use a named profile")
208
+
209
+ # logout subcommand
210
+ logout_parser = subparsers.add_parser("logout", help="Remove saved authentication token")
211
+ logout_parser.add_argument("--profile", "-P", default=argparse.SUPPRESS, help="Use a named profile")
212
+
213
+ # UI flags (only apply when no subcommand)
214
+ parser.add_argument(
215
+ "--validate",
216
+ action="store_true",
217
+ help="Validate credentials and exit without launching the UI",
218
+ )
219
+ parser.add_argument(
220
+ "--verbose", "-v",
221
+ action="store_true",
222
+ help="Enable verbose/debug logging",
223
+ )
224
+ parser.add_argument(
225
+ "--log-file",
226
+ help="Write logs to file (in addition to console)",
227
+ )
228
+ parser.add_argument(
229
+ "--port",
230
+ type=int,
231
+ default=7860,
232
+ help="Gradio server port (default: 7860)",
233
+ )
234
+ parser.add_argument(
235
+ "--share",
236
+ action="store_true",
237
+ help="Enable Gradio public sharing link",
238
+ )
239
+
240
+ args = parser.parse_args()
241
+
242
+ # Handle --list-profiles
243
+ if args.list_profiles:
244
+ cmd_list_profiles(args)
245
+ sys.exit(0)
246
+
247
+ # Handle subcommands
248
+ if args.command == "login":
249
+ if args.profile:
250
+ set_active_profile(args.profile)
251
+ cmd_login(args)
252
+ elif args.command == "logout":
253
+ cmd_logout(args)
254
+ else:
255
+ cmd_ui(args)
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()
dedrive/config.py ADDED
@@ -0,0 +1,284 @@
1
+ """Configuration module for Google Drive Deduplication Tool."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ from dedrive.profiles import load_profile, get_profile_token_path, get_profile_output_dir, init_profile, PROFILES_DIR
11
+
12
+ # Load .env file if present
13
+ load_dotenv()
14
+
15
+ # Active profile (set via set_active_profile)
16
+ active_profile: str | None = None
17
+ _profile_config: dict = {}
18
+
19
+
20
+ def set_active_profile(name: str):
21
+ """Activate a profile, loading its config.yaml."""
22
+ global active_profile, _profile_config
23
+ active_profile = name
24
+ _profile_config = load_profile(name)
25
+
26
+
27
+ def set_active_profile_from_email(email: str) -> str:
28
+ """Create (if needed) and activate a profile based on user email.
29
+
30
+ Args:
31
+ email: The user's email address, used as the profile name.
32
+
33
+ Returns:
34
+ The profile name (same as email).
35
+ """
36
+ init_profile(email)
37
+ set_active_profile(email)
38
+ return email
39
+
40
+
41
+ # Default configuration values
42
+ DEFAULTS = {
43
+ "credentials_path": "credentials.json",
44
+ "token_path": "token.json",
45
+ "output_dir": ".output",
46
+ "dupes_folder": "/_dupes",
47
+ "batch_size": 100,
48
+ "max_preview_mb": 10,
49
+ "exclude_paths": [],
50
+ }
51
+
52
+ # Environment variable names
53
+ ENV_VARS = {
54
+ "credentials_path": "GDRIVE_CREDENTIALS_PATH",
55
+ "token_path": "GDRIVE_TOKEN_PATH",
56
+ "output_dir": "GDRIVE_OUTPUT_DIR",
57
+ "dupes_folder": "GDRIVE_DUPES_FOLDER",
58
+ "batch_size": "GDRIVE_BATCH_SIZE",
59
+ "max_preview_mb": "GDRIVE_MAX_PREVIEW_MB",
60
+ "exclude_paths": "GDRIVE_EXCLUDE_PATHS",
61
+ }
62
+
63
+ CONFIG_FILE = "config.json"
64
+
65
+
66
+ def expand_path(path: str) -> Path:
67
+ """Expand ~ and environment variables in path."""
68
+ return Path(os.path.expanduser(os.path.expandvars(path)))
69
+
70
+
71
+ def load_config() -> dict:
72
+ """Load configuration from config file if it exists.
73
+
74
+ Checks cwd config.json first, then falls back to ~/.dedrive/config.json.
75
+ """
76
+ for config_path in [Path(CONFIG_FILE), PROFILES_DIR / CONFIG_FILE]:
77
+ if config_path.exists():
78
+ try:
79
+ with open(config_path) as f:
80
+ return json.load(f)
81
+ except json.JSONDecodeError as e:
82
+ print(f"Error: Invalid JSON in {config_path}: {e}")
83
+ print(f"Please fix the syntax in {config_path} or delete it to use defaults.")
84
+ except PermissionError:
85
+ print(f"Error: Cannot read {config_path} - permission denied.")
86
+ except Exception as e:
87
+ print(f"Warning: Failed to load {config_path}: {e}")
88
+ return {}
89
+
90
+
91
+ def get_config_value(key: str, cli_value: Any = None) -> Any:
92
+ """Get configuration value with precedence: CLI > ENV > config file > default.
93
+
94
+ Args:
95
+ key: Configuration key (e.g., 'credentials_path')
96
+ cli_value: Value from CLI argument (highest precedence if not None)
97
+
98
+ Returns:
99
+ Configuration value from highest precedence source.
100
+ """
101
+ # CLI argument has highest precedence
102
+ if cli_value is not None:
103
+ return cli_value
104
+
105
+ # Profile config.yaml (when a profile is active)
106
+ if active_profile and key in _profile_config:
107
+ return _profile_config[key]
108
+
109
+ # Environment variable
110
+ env_var = ENV_VARS.get(key)
111
+ if env_var:
112
+ env_value = os.environ.get(env_var)
113
+ if env_value is not None:
114
+ # Handle type conversion
115
+ if key == "batch_size":
116
+ try:
117
+ return int(env_value)
118
+ except ValueError:
119
+ print(f"Warning: Invalid {env_var} value '{env_value}', using default.")
120
+ elif key == "max_preview_mb":
121
+ try:
122
+ return int(env_value)
123
+ except ValueError:
124
+ print(f"Warning: Invalid {env_var} value '{env_value}', using default.")
125
+ else:
126
+ return env_value
127
+
128
+ # Config file
129
+ config = load_config()
130
+ if key in config:
131
+ return config[key]
132
+
133
+ # Default
134
+ return DEFAULTS.get(key)
135
+
136
+
137
+ def get_credentials_path(cli_value: str = None) -> Path:
138
+ """Get credentials file path.
139
+
140
+ Falls back to ~/.dedrive/credentials.json when the default
141
+ credentials.json doesn't exist in cwd.
142
+ """
143
+ path = get_config_value("credentials_path", cli_value)
144
+ resolved = expand_path(path)
145
+ if not resolved.exists() and path == DEFAULTS["credentials_path"]:
146
+ fallback = PROFILES_DIR / "credentials.json"
147
+ if fallback.exists():
148
+ return fallback
149
+ return resolved
150
+
151
+
152
+ def get_token_path(credentials_path: Path = None) -> Path:
153
+ """Get token file path.
154
+
155
+ By default, token.json is stored next to credentials.json.
156
+ Can be overridden via GDRIVE_TOKEN_PATH or config file.
157
+ """
158
+ if active_profile:
159
+ return get_profile_token_path(active_profile)
160
+
161
+ explicit_path = get_config_value("token_path")
162
+
163
+ # If explicitly set (not default), use that
164
+ if explicit_path != DEFAULTS["token_path"]:
165
+ return expand_path(explicit_path)
166
+
167
+ # Otherwise, store next to credentials file
168
+ if credentials_path:
169
+ return credentials_path.parent / "token.json"
170
+
171
+ return Path("token.json")
172
+
173
+
174
+ def get_output_dir() -> Path:
175
+ """Get output directory path."""
176
+ if active_profile:
177
+ return get_profile_output_dir(active_profile)
178
+ path = get_config_value("output_dir")
179
+ return expand_path(path)
180
+
181
+
182
+ def get_dupes_folder() -> str:
183
+ """Get the name of the dupes folder in Google Drive."""
184
+ return get_config_value("dupes_folder")
185
+
186
+
187
+ def get_batch_size() -> int:
188
+ """Get batch size for API operations."""
189
+ return get_config_value("batch_size")
190
+
191
+
192
+ def get_max_preview_size() -> int:
193
+ """Get max preview size in bytes."""
194
+ mb = get_config_value("max_preview_mb")
195
+ return mb * 1024 * 1024
196
+
197
+
198
+ def get_exclude_paths(cli_excludes: list[str] = None) -> list[str]:
199
+ """Get exclude paths from CLI, config file, and environment variable.
200
+
201
+ Sources (combined):
202
+ 1. CLI arguments (--exclude flags)
203
+ 2. Config file: config.json with "exclude_paths" array
204
+ 3. Environment variable: GDRIVE_EXCLUDE_PATHS (comma-separated paths)
205
+
206
+ Returns:
207
+ List of paths to exclude from scans.
208
+ """
209
+ exclude_paths = []
210
+
211
+ # CLI arguments
212
+ if cli_excludes:
213
+ exclude_paths.extend(cli_excludes)
214
+
215
+ # Profile config.yaml (when a profile is active)
216
+ if active_profile and "exclude_paths" in _profile_config:
217
+ profile_paths = _profile_config["exclude_paths"]
218
+ if isinstance(profile_paths, list):
219
+ exclude_paths.extend(profile_paths)
220
+
221
+ # Load from config file
222
+ config = load_config()
223
+ file_paths = config.get("exclude_paths", [])
224
+ if isinstance(file_paths, list):
225
+ exclude_paths.extend(file_paths)
226
+
227
+ # Load from environment variable (comma-separated)
228
+ env_var = ENV_VARS.get("exclude_paths")
229
+ env_paths = os.environ.get(env_var, "")
230
+ if env_paths:
231
+ for path in env_paths.split(","):
232
+ path = path.strip()
233
+ if path:
234
+ exclude_paths.append(path)
235
+
236
+ # Normalize paths (ensure they start with / and don't end with /)
237
+ normalized = []
238
+ for path in exclude_paths:
239
+ path = path.strip()
240
+ # Skip comment lines in config
241
+ if path.startswith("#"):
242
+ continue
243
+ if not path.startswith("/"):
244
+ path = "/" + path
245
+ path = path.rstrip("/")
246
+ if path:
247
+ normalized.append(path)
248
+
249
+ return list(set(normalized)) # Remove duplicates
250
+
251
+
252
+ def create_default_config():
253
+ """Create a default config file with all available options."""
254
+ default_config = {
255
+ "# credentials_path": "~/.config/dedrive/credentials.json",
256
+ "# token_path": "~/.config/dedrive/token.json",
257
+ "# output_dir": ".output",
258
+ "# dupes_folder": "/_dupes",
259
+ "# batch_size": 100,
260
+ "# max_preview_mb": 10,
261
+ "exclude_paths": [
262
+ "# Add paths to exclude from scans, e.g.:",
263
+ "# /Backup/Old",
264
+ "# /tmp"
265
+ ]
266
+ }
267
+
268
+ config_path = Path(CONFIG_FILE)
269
+ if not config_path.exists():
270
+ with open(config_path, "w") as f:
271
+ json.dump(default_config, f, indent=2)
272
+ print(f"Created default config file: {CONFIG_FILE}")
273
+
274
+
275
+ def print_config():
276
+ """Print current configuration for debugging."""
277
+ print("Current configuration:")
278
+ print(f" credentials_path: {get_credentials_path()}")
279
+ print(f" token_path: {get_token_path(get_credentials_path())}")
280
+ print(f" output_dir: {get_output_dir()}")
281
+ print(f" dupes_folder: {get_dupes_folder()}")
282
+ print(f" batch_size: {get_batch_size()}")
283
+ print(f" max_preview_mb: {get_config_value('max_preview_mb')}")
284
+ print(f" exclude_paths: {get_exclude_paths()}")