foldrive 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.
foldrive/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__="0.1.0"
foldrive/auth.py ADDED
@@ -0,0 +1,59 @@
1
+ from google.auth.exceptions import RefreshError
2
+ from google.auth.transport.requests import Request
3
+ from google.oauth2.credentials import Credentials
4
+ from google_auth_oauthlib.flow import InstalledAppFlow
5
+
6
+ from .paths import APP_DIR, TOKEN_PATH
7
+
8
+ SCOPES = ["https://www.googleapis.com/auth/drive"]
9
+ CLIENT_SECRET_PATH = APP_DIR / "client_secret.json"
10
+
11
+
12
+ def _save(creds):
13
+ APP_DIR.mkdir(parents=True, exist_ok=True)
14
+ TOKEN_PATH.write_text(creds.to_json(), encoding="utf-8")
15
+
16
+
17
+
18
+ def get_credentials():
19
+ if not TOKEN_PATH.exists():
20
+ return None
21
+ creds = Credentials.from_authorized_user_file(str(TOKEN_PATH),SCOPES)
22
+ if creds.valid:
23
+ return creds
24
+ if creds.expired and creds.refresh_token:
25
+ try:
26
+ creds.refresh(Request())
27
+ except RefreshError:
28
+ TOKEN_PATH.unlink()
29
+ return None
30
+ _save(creds)
31
+ return creds
32
+ return None
33
+
34
+ def login():
35
+ creds=get_credentials()
36
+ if creds:
37
+ return creds
38
+ flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_PATH), SCOPES)
39
+ creds = flow.run_local_server(port=0)
40
+ _save(creds)
41
+ return creds
42
+
43
+ def logout():
44
+ if TOKEN_PATH.exists():
45
+ TOKEN_PATH.unlink()
46
+ return True
47
+ return False
48
+
49
+
50
+ def get_service():
51
+ """Build a Drive client, or exit with a hint if not logged in."""
52
+ from googleapiclient.discovery import build
53
+
54
+ credentials = get_credentials()
55
+ if credentials is None:
56
+ raise SystemExit("Not logged in. Run: foldrive login")
57
+ return build("drive", "v3", credentials=credentials)
58
+
59
+
foldrive/autostart.py ADDED
@@ -0,0 +1,178 @@
1
+ """Registers `foldrive tick` to run in the background.
2
+
3
+ Windows uses Task Scheduler; everything else uses cron. The public install/
4
+ uninstall/status functions dispatch on platform, so each backend below can assume
5
+ it is only ever called on its own OS.
6
+ """
7
+
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ TASK_NAME = "foldrive-tick"
14
+ INTERVAL_MINUTES = 5
15
+
16
+ # schtasks' defaults are wrong for a laptop sync tool: it refuses to start on
17
+ # battery and kills a running task the moment you unplug. Those flags can't be set
18
+ # from the schtasks command line, and registering from XML needs elevation, so the
19
+ # task is created the plain way and then patched through the PowerShell API.
20
+ POWER_SETTINGS_SCRIPT = f"""
21
+ $task = Get-ScheduledTask -TaskName '{TASK_NAME}'
22
+ $task.Settings.DisallowStartIfOnBatteries = $false
23
+ $task.Settings.StopIfGoingOnBatteries = $false
24
+ $task.Settings.MultipleInstances = 'IgnoreNew'
25
+ $task.Settings.StartWhenAvailable = $true
26
+ $task | Set-ScheduledTask | Out-Null
27
+ """
28
+
29
+ def _foldrive_command():
30
+ """The command Task Scheduler should run, however foldrive was installed.
31
+
32
+ Always an absolute path: the task runs with a different environment and
33
+ working directory, so a bare `foldrive` may not resolve.
34
+
35
+ Prefers pythonw.exe: it belongs to the GUI subsystem, so Windows never
36
+ allocates a console for it. Running foldrive.exe (a console script) would
37
+ flash a command prompt on screen at every scheduled tick.
38
+ """
39
+ if getattr(sys, "frozen", False):
40
+ # Standalone foldrive.exe (PyInstaller build; needs --noconsole to be quiet).
41
+ return f'"{sys.executable}" tick'
42
+
43
+ windowless_python = Path(sys.executable).with_name("pythonw.exe")
44
+ if windowless_python.exists():
45
+ return f'"{windowless_python}" -m foldrive.cli tick'
46
+
47
+ installed_path = shutil.which("foldrive")
48
+ if installed_path:
49
+ return f'"{installed_path}" tick'
50
+
51
+ # Installed but not on PATH - call this same interpreter's module directly.
52
+ return f'"{sys.executable}" -m foldrive.cli tick'
53
+
54
+
55
+ def _allow_running_on_battery():
56
+ """Returns True if the power settings were relaxed.
57
+
58
+ Best-effort: if it fails, the task still syncs whenever the machine is plugged
59
+ in, which is worth keeping rather than failing the whole install over.
60
+ """
61
+ result = subprocess.run(
62
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", POWER_SETTINGS_SCRIPT],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ return result.returncode == 0
67
+
68
+
69
+ def install():
70
+ if sys.platform == "win32":
71
+ return _install_windows()
72
+ return _install_cron()
73
+
74
+ def uninstall():
75
+ if sys.platform == "win32":
76
+ return _uninstall_windows()
77
+ return _uninstall_cron()
78
+
79
+
80
+ def status():
81
+ if sys.platform == "win32":
82
+ return _status_windows()
83
+ return _status_cron()
84
+
85
+
86
+
87
+ def _install_windows():
88
+ subprocess.run([
89
+ "schtasks", "/create", "/tn", TASK_NAME,
90
+ "/tr", _foldrive_command(),
91
+ "/sc", "minute", "/mo", str(INTERVAL_MINUTES),
92
+ "/f",
93
+ ], check=True)
94
+ return _allow_running_on_battery()
95
+
96
+
97
+ def _uninstall_windows():
98
+ subprocess.run(["schtasks", "/delete", "/tn", TASK_NAME, "/f"], check=True)
99
+
100
+
101
+ def _status_windows():
102
+ result = subprocess.run(
103
+ ["schtasks", "/query", "/tn", TASK_NAME], capture_output=True, text=True
104
+ )
105
+ return result.stdout.strip() if result.returncode == 0 else "not registered"
106
+
107
+ CRON_MARKER = "# foldrive-tick"
108
+
109
+
110
+ def _read_crontab():
111
+ """Empty string when there is no crontab yet.
112
+
113
+ `crontab -l` exits 1 with "no crontab for <user>" in that case - a normal
114
+ first-run state, not a failure, so this must not use check=True.
115
+
116
+ Any OTHER failure must raise rather than return "": the caller rewrites the
117
+ whole crontab from what this returns, so mistaking an unreadable crontab for
118
+ an empty one would delete every job the user has.
119
+ """
120
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
121
+ if result.returncode == 0:
122
+ return result.stdout
123
+ if "no crontab" in result.stderr.lower():
124
+ return ""
125
+ raise SystemExit(f"Could not read the crontab: {result.stderr.strip()}")
126
+
127
+
128
+ def _write_crontab(lines):
129
+ """cron has no 'append' - the whole crontab is replaced every time. That is
130
+ exactly why every foldrive line carries CRON_MARKER: so we can strip ours and
131
+ put everyone else's back untouched."""
132
+ text = "\n".join(lines).strip() + "\n"
133
+ result = subprocess.run(["crontab", "-"], input=text, text=True, capture_output=True)
134
+ if result.returncode != 0:
135
+ raise SystemExit(f"Could not update crontab: {result.stderr.strip()}")
136
+
137
+
138
+ def _without_foldrive_lines(text):
139
+ return [line for line in text.splitlines() if CRON_MARKER not in line]
140
+
141
+
142
+ def _cron_command():
143
+ """Absolute path: cron runs with a minimal PATH (usually /usr/bin:/bin), so a
144
+ bare `foldrive` may not resolve even though it works in your shell."""
145
+ installed_path = shutil.which("foldrive")
146
+ if installed_path:
147
+ return f"{installed_path} tick"
148
+ return f"{sys.executable} -m foldrive.cli tick"
149
+
150
+
151
+ def _install_cron():
152
+ if shutil.which("crontab") is None:
153
+ raise SystemExit(
154
+ "No `crontab` command found. Install cron, or run `foldrive sync` manually."
155
+ )
156
+ lines = _without_foldrive_lines(_read_crontab())
157
+ # >/dev/null: cron mails the user any output a job produces, and foldrive
158
+ # already writes everything worth keeping to its log file.
159
+ lines.append(
160
+ f"*/{INTERVAL_MINUTES} * * * * {_cron_command()} >/dev/null 2>&1 {CRON_MARKER}"
161
+ )
162
+ _write_crontab(lines)
163
+ return True # cron has no battery restriction to relax
164
+
165
+
166
+ def _uninstall_cron():
167
+ existing = _read_crontab()
168
+ remaining = _without_foldrive_lines(existing)
169
+ if len(remaining) == len(existing.splitlines()):
170
+ raise SystemExit("Background sync was not enabled.")
171
+ _write_crontab(remaining)
172
+
173
+
174
+ def _status_cron():
175
+ for line in _read_crontab().splitlines():
176
+ if CRON_MARKER in line:
177
+ return line.strip()
178
+ return "not registered"
foldrive/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """foldrive CLI: argument parsing and dispatch. Command logic lives in commands/."""
2
+
3
+ import argparse
4
+
5
+ from . import __version__
6
+ from .commands import (
7
+ autostart, init, log, login, logout, ls, pull, push, restore, setup, status,
8
+ sync, tick, whoami,
9
+ )
10
+
11
+
12
+ def main():
13
+ parser = argparse.ArgumentParser(
14
+ prog="foldrive",
15
+ description="Pair a local folder with a Google Drive folder.",
16
+ )
17
+
18
+ parser.add_argument(
19
+ "--version",
20
+ action="version",
21
+ version=f"foldrive {__version__}",
22
+ )
23
+
24
+ sub = parser.add_subparsers(
25
+ dest="command",
26
+ required=True,
27
+ )
28
+
29
+ p = sub.add_parser(
30
+ "setup",
31
+ help="One-time Google setup: install your OAuth client file",
32
+ )
33
+ p.add_argument(
34
+ "path",
35
+ nargs="?",
36
+ default=None,
37
+ help="Path to the client JSON downloaded from Google Cloud "
38
+ "(omit to print the setup steps)",
39
+ )
40
+ p.add_argument(
41
+ "--force",
42
+ action="store_true",
43
+ help="Replace an already-installed client file",
44
+ )
45
+ p.set_defaults(func=setup.run)
46
+
47
+ p = sub.add_parser(
48
+ "login",
49
+ help="Sign in with your Google account",
50
+ )
51
+ p.set_defaults(func=login.run)
52
+
53
+ p = sub.add_parser(
54
+ "whoami",
55
+ help="Show the currently signed-in Google account",
56
+ )
57
+ p.set_defaults(func=whoami.run)
58
+
59
+ p = sub.add_parser(
60
+ "logout",
61
+ help="Sign out and forget the saved Google token",
62
+ )
63
+ p.set_defaults(func=logout.run)
64
+
65
+ p = sub.add_parser(
66
+ "ls",
67
+ help="List the contents of a Drive folder by name",
68
+ )
69
+ p.add_argument(
70
+ "name",
71
+ nargs="?",
72
+ default=None,
73
+ help="Drive folder name to look up (you'll be asked if omitted)",
74
+ )
75
+ p.set_defaults(func=ls.run)
76
+
77
+ p = sub.add_parser(
78
+ "init",
79
+ help="Initialize the current folder for foldrive syncing",
80
+ )
81
+ p.set_defaults(func=init.run)
82
+
83
+ p = sub.add_parser(
84
+ "status",
85
+ help="Show local and remote sync status",
86
+ )
87
+ p.add_argument(
88
+ "--all",
89
+ action="store_true",
90
+ help="List every pending file instead of the first 40",
91
+ )
92
+ p.set_defaults(func=status.run)
93
+
94
+ p = sub.add_parser(
95
+ "push",
96
+ help="Upload local changes to Google Drive",
97
+ )
98
+ p.add_argument(
99
+ "--yes",
100
+ action="store_true",
101
+ help="Skip the first-sync confirmation prompt",
102
+ )
103
+ p.add_argument(
104
+ "--allow-mass-delete",
105
+ action="store_true",
106
+ help="Permit a run that would delete most of the folder",
107
+ )
108
+ p.set_defaults(func=push.run)
109
+
110
+ p = sub.add_parser(
111
+ "pull",
112
+ help="Download remote changes from Google Drive",
113
+ )
114
+ p.add_argument(
115
+ "--yes",
116
+ action="store_true",
117
+ help="Never prompt; use safe defaults",
118
+ )
119
+ p.add_argument(
120
+ "--allow-mass-delete",
121
+ action="store_true",
122
+ help="Permit a run that would delete most of the folder",
123
+ )
124
+ p.set_defaults(func=pull.run)
125
+
126
+ p = sub.add_parser(
127
+ "sync",
128
+ help="Synchronize local and Google Drive changes",
129
+ )
130
+ p.add_argument(
131
+ "--yes",
132
+ action="store_true",
133
+ help="Never prompt; use safe defaults",
134
+ )
135
+ p.add_argument(
136
+ "--allow-mass-delete",
137
+ action="store_true",
138
+ help="Permit a run that would delete most of the folder",
139
+ )
140
+ p.set_defaults(func=sync.run)
141
+
142
+ p = sub.add_parser(
143
+ "log",
144
+ help="Show what foldrive has done to this folder",
145
+ )
146
+ p.add_argument(
147
+ "-n", "--number",
148
+ type=int,
149
+ default=20,
150
+ help="How many entries to show (default 20)",
151
+ )
152
+ p.add_argument(
153
+ "--all",
154
+ action="store_true",
155
+ help="Show the entire history",
156
+ )
157
+ p.set_defaults(func=log.run)
158
+
159
+ p = sub.add_parser(
160
+ "restore",
161
+ help="Bring one file back from Drive",
162
+ )
163
+ p.add_argument(
164
+ "path",
165
+ help="Path of the file inside this folder, e.g. notes/unit-3.pdf",
166
+ )
167
+ p.add_argument(
168
+ "--force",
169
+ action="store_true",
170
+ help="Overwrite the local file if it already exists",
171
+ )
172
+ p.set_defaults(func=restore.run)
173
+
174
+ p = sub.add_parser(
175
+ "tick",
176
+ help="Run any scheduled syncs that are due (used by the background task)",
177
+ )
178
+ p.set_defaults(func=tick.run)
179
+
180
+ p = sub.add_parser(
181
+ "autostart",
182
+ help="Enable foldrive to start automatically when you log in",
183
+ )
184
+ p.add_argument("--remove", action="store_true",help="Disable background sync")
185
+ p.add_argument("--status",action="store_true",help="Show whether it's registered")
186
+ p.set_defaults(func=autostart.run)
187
+
188
+ args = parser.parse_args()
189
+ try:
190
+ args.func(args)
191
+ except KeyboardInterrupt:
192
+ # Ctrl-C is a normal way to stop a long sync, not a crash. Each command
193
+ # saves its snapshot in a `finally`, so whatever finished is already
194
+ # recorded and the next run picks up from there.
195
+ raise SystemExit("\nInterrupted. Finished files are saved - re-run to continue.")
196
+
197
+
198
+ if __name__ == "__main__":
199
+ main()
@@ -0,0 +1 @@
1
+ """One module per CLI command; each exposes run(args)."""
@@ -0,0 +1,16 @@
1
+ from .. import autostart,logs
2
+
3
+ def run(args):
4
+ if(args.remove):
5
+ autostart.uninstall()
6
+ print("Background Sync Disabled")
7
+ return
8
+ if args.status:
9
+ print(autostart.status())
10
+ return
11
+ runs_on_battery = autostart.install()
12
+ print(f"Background sync enabled - foldrive tick runs every {autostart.INTERVAL_MINUTES} minutes")
13
+ if not runs_on_battery:
14
+ print("Note: could not allow syncing on battery power - Windows will only")
15
+ print(" run the background sync while this machine is plugged in.")
16
+ print(f"Log: {logs.LOG_PATH}")
@@ -0,0 +1,57 @@
1
+ import copy
2
+ from pathlib import Path
3
+
4
+ from googleapiclient.errors import HttpError
5
+
6
+ from .. import auth, config, drive
7
+
8
+
9
+ def run(args):
10
+ folder = Path.cwd()
11
+
12
+ if (folder / config.CONFIG_NAME).exists():
13
+ raise SystemExit("Already initialized. Edit .googledrive.json to change settings.")
14
+
15
+ enclosing_root = config.find_config_root(folder.parent)
16
+ if enclosing_root is not None:
17
+ raise SystemExit(f"This folder is inside '{enclosing_root}', which is already synced.")
18
+
19
+ service = auth.get_service()
20
+ try:
21
+ matching_folders = drive.find_folder_by_name(service, folder.name)
22
+
23
+ if len(matching_folders) > 1:
24
+ print(f"Multiple Drive folders are named '{folder.name}':")
25
+ for folder_match in matching_folders:
26
+ print(f" {folder_match['name']} (id: {folder_match['id']})")
27
+ raise SystemExit(
28
+ "foldrive can't tell them apart yet. Rename the extras in Drive, then re-run init."
29
+ )
30
+
31
+ elif len(matching_folders) == 1:
32
+ drive_folder_id = matching_folders[0]["id"]
33
+ answer = input(f"Found Drive folder '{folder.name}'. Link this local folder to it? [y/N] ")
34
+ if answer.strip().lower() != "y":
35
+ raise SystemExit("Cancelled. Nothing was created or linked.")
36
+
37
+ else:
38
+ answer = input(f"No Drive folder named '{folder.name}' found. Create it in My Drive? [y/N] ")
39
+ if answer.strip().lower() != "y":
40
+ raise SystemExit("Cancelled. Nothing was created or linked.")
41
+ drive_folder_id = drive.create_folder(service, folder.name)
42
+ print(f"Created Drive folder '{folder.name}'.")
43
+
44
+ new_config = copy.deepcopy(config.DEFAULT_CONFIG)
45
+ new_config["drive_folder_id"] = drive_folder_id
46
+ new_config["drive_folder_name"] = folder.name
47
+ config.save_config(folder, new_config)
48
+ config.register_folder(folder)
49
+
50
+ print(f"Linked '{folder}' <-> Drive folder '{folder.name}'.")
51
+ print("Settings saved in .googledrive.json — edit schedules/ignores there.")
52
+ print("Next: run `foldrive status` to see what a first sync would do.")
53
+
54
+ except HttpError as api_error:
55
+ raise SystemExit(f"Google Drive API error: {api_error.reason}")
56
+ except OSError:
57
+ raise SystemExit("Network error — are you connected to the internet?")
@@ -0,0 +1,67 @@
1
+ """Show what foldrive did to this folder, most recent first."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from .. import config, history
7
+
8
+ # Past tense: this is a record of what happened, not a plan.
9
+ KIND_LABELS = {
10
+ "upload_new": "uploaded",
11
+ "upload_changed": "updated in Drive",
12
+ "download_new": "downloaded",
13
+ "download_changed": "updated locally",
14
+ "trash_remote": "trashed in Drive",
15
+ "recycle_local": "moved to Recycle Bin",
16
+ "link": "linked",
17
+ "download_new_doc": "downloaded Doc",
18
+ "download_changed_doc": "downloaded Doc again",
19
+ "download_changed_doc_keep_local": "downloaded Doc, kept local copy",
20
+ "upload_changed_doc": "uploaded into Doc",
21
+ "upload_changed_doc_keep_drive": "uploaded into Doc, kept Drive copy",
22
+ "restore": "restored from Drive",
23
+ }
24
+
25
+ # A gap this long means a different run - blank line between runs so the history
26
+ # reads as sessions rather than one undifferentiated stream.
27
+ RUN_GAP_SECONDS = 120
28
+
29
+
30
+ def _local_time(iso_text):
31
+ try:
32
+ return datetime.fromisoformat(iso_text).astimezone()
33
+ except (ValueError, TypeError):
34
+ return None
35
+
36
+
37
+ def run(args):
38
+ folder = config.find_config_root(Path.cwd())
39
+ if folder is None:
40
+ raise SystemExit("Not a foldrive folder. Run: foldrive init")
41
+
42
+ limit = None if args.all else args.number
43
+ entries = history.read(folder, limit)
44
+
45
+ if not entries:
46
+ print("No history yet for this folder.")
47
+ print("It is written as foldrive syncs; run `foldrive sync` first.")
48
+ return
49
+
50
+ print(f"Folder : {folder}")
51
+ print(f"History: {history.history_path(folder)}\n")
52
+
53
+ previous_time = None
54
+ for entry in entries:
55
+ moment = _local_time(entry.get("at"))
56
+ if (previous_time and moment
57
+ and (previous_time - moment).total_seconds() > RUN_GAP_SECONDS):
58
+ print()
59
+ previous_time = moment
60
+
61
+ stamp = moment.strftime("%Y-%m-%d %H:%M:%S") if moment else "unknown time"
62
+ label = KIND_LABELS.get(entry.get("kind"), entry.get("kind", "?"))
63
+ print(f" {stamp} {label:<28} {entry.get('path', '')}")
64
+
65
+ if not args.all:
66
+ print(f"\nShowing the last {len(entries)}. Use --all for everything, "
67
+ f"-n N for a different count.")
@@ -0,0 +1,10 @@
1
+ from .. import auth
2
+
3
+
4
+ def run(args):
5
+ already = auth.get_credentials() is not None
6
+ auth.login()
7
+ if already:
8
+ print("Already logged in.")
9
+ else:
10
+ print(f"Logged in. Token saved to {auth.TOKEN_PATH}")
@@ -0,0 +1,8 @@
1
+ from .. import auth
2
+
3
+
4
+ def run(args):
5
+ if auth.logout():
6
+ print("Logged out.")
7
+ else:
8
+ print("Not logged in.")
@@ -0,0 +1,42 @@
1
+ from googleapiclient.errors import HttpError
2
+
3
+ from .. import auth, drive
4
+
5
+
6
+ def run(args):
7
+ if args.name is None:
8
+ args.name = input("Enter the Drive folder name: ").strip()
9
+ if not args.name:
10
+ raise SystemExit("No folder name given.")
11
+
12
+ service = auth.get_service()
13
+ try:
14
+ matching_folders = drive.find_folder_by_name(service, args.name)
15
+ if not matching_folders:
16
+ print(f"No Drive folder named '{args.name}' found.")
17
+ return
18
+ if len(matching_folders) > 1:
19
+ print(f"Multiple folders named '{args.name}' found.")
20
+ for folder_match in matching_folders:
21
+ print(f" {folder_match['name']} (id: {folder_match['id']})")
22
+ return
23
+
24
+ children = drive.list_children(service, matching_folders[0]["id"])
25
+ if not children:
26
+ print("(empty folder)")
27
+ return
28
+ folders_first_alphabetical = sorted(
29
+ children,
30
+ key=lambda child: (child["mimeType"] != drive.FOLDER_MIME_TYPE, child["name"].lower()),
31
+ )
32
+ print(f"Found one folder with the name '{args.name}', following are the contents in Google Drive of that folder: ")
33
+ for child in folders_first_alphabetical:
34
+ if child["mimeType"] == drive.FOLDER_MIME_TYPE:
35
+ print(f" {child['name']}/")
36
+ else:
37
+ size_kb = int(child.get("size", 0)) // 1024
38
+ print(f" {child['name']} ({size_kb} KB)")
39
+ except HttpError as api_error:
40
+ raise SystemExit(f"Google Drive API error: {api_error.reason}")
41
+ except OSError:
42
+ raise SystemExit("Network error — are you connected to the internet?")