gfunk 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.
gfunk/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("gfunk")
gfunk/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from gfunk.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
gfunk/auth.py ADDED
@@ -0,0 +1,119 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ from google.auth.transport.requests import Request
5
+ from google.oauth2.credentials import Credentials
6
+ from google_auth_oauthlib.flow import InstalledAppFlow
7
+
8
+ from gfunk.bootstrap import classify, diagnose
9
+ from gfunk.browser import hyperlink
10
+ from gfunk.browser import register as register_browser
11
+
12
+ SCOPES = [
13
+ "https://www.googleapis.com/auth/spreadsheets",
14
+ "https://www.googleapis.com/auth/drive",
15
+ "https://www.googleapis.com/auth/script.processes",
16
+ "https://www.googleapis.com/auth/script.projects",
17
+ "https://www.googleapis.com/auth/gmail.modify",
18
+ ]
19
+
20
+ CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
21
+
22
+ DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gfunk"
23
+ DEFAULT_CLIENT_SECRETS = DEFAULT_CONFIG_DIR / "credentials.json"
24
+ DEFAULT_TOKEN_PATH = DEFAULT_CONFIG_DIR / "token.json"
25
+
26
+
27
+ def _scopes_changed(token_path: Path, expected: list[str]) -> bool:
28
+ """True when the cached token was issued for different scopes."""
29
+ import json
30
+
31
+ try:
32
+ stored = json.loads(token_path.read_text())
33
+ except (json.JSONDecodeError, OSError):
34
+ return False
35
+ saved = stored.get("scopes")
36
+ if not saved:
37
+ return False
38
+ return set(expected) != set(saved)
39
+
40
+
41
+ def granted_scopes(token_path: Path = DEFAULT_TOKEN_PATH) -> set[str]:
42
+ """Scopes the cached token actually carries, for feature-level opt-in checks."""
43
+ import json
44
+
45
+ try:
46
+ stored = json.loads(token_path.read_text())
47
+ except (json.JSONDecodeError, OSError):
48
+ return set()
49
+ return set(stored.get("scopes") or [])
50
+
51
+
52
+ TokenState = Literal["none", "signed-in", "refreshable", "stale"]
53
+
54
+
55
+ def token_state(
56
+ token_path: Path = DEFAULT_TOKEN_PATH, scopes: list[str] | None = None
57
+ ) -> TokenState:
58
+ """What the cached token is still good for, without touching the network."""
59
+ if not token_path.exists():
60
+ return "none"
61
+ expected = scopes if scopes is not None else SCOPES
62
+ if _scopes_changed(token_path, expected):
63
+ return "stale"
64
+ try:
65
+ creds = Credentials.from_authorized_user_file(str(token_path), expected)
66
+ except (ValueError, OSError):
67
+ return "stale"
68
+
69
+ if creds.valid:
70
+ return "signed-in"
71
+ return "refreshable" if creds.expired and creds.refresh_token else "stale"
72
+
73
+
74
+ def authorize_prompt() -> str:
75
+ """A one-click link; the raw URL only where clicking is impossible anyway."""
76
+ link = hyperlink("Authorize gfunk in Google", "{url}")
77
+ return f"\nOpening your browser to sign in.\nIf nothing opens, visit:\n {link}\n"
78
+
79
+
80
+ class MissingClientSecretsError(Exception):
81
+ """gfunk ships no credentials; the user registers their own GCP OAuth client."""
82
+
83
+
84
+ def get_down(
85
+ client_secrets: Path = DEFAULT_CLIENT_SECRETS,
86
+ token_path: Path = DEFAULT_TOKEN_PATH,
87
+ scopes: list[str] | None = None,
88
+ ) -> Credentials:
89
+ """Complete first-run OAuth and cache the resulting token."""
90
+ scopes = scopes if scopes is not None else SCOPES
91
+ creds: Credentials | None = None
92
+
93
+ if token_path.exists():
94
+ if _scopes_changed(token_path, scopes):
95
+ token_path.unlink()
96
+ else:
97
+ creds = Credentials.from_authorized_user_file(str(token_path), scopes)
98
+
99
+ if creds and creds.valid:
100
+ return creds
101
+
102
+ if creds and creds.expired and creds.refresh_token:
103
+ creds.refresh(Request())
104
+ else:
105
+ kind = classify(client_secrets)
106
+ if kind != "installed":
107
+ raise MissingClientSecretsError(diagnose(kind, client_secrets))
108
+ flow = InstalledAppFlow.from_client_secrets_file(str(client_secrets), scopes)
109
+ register_browser()
110
+ creds = flow.run_local_server(
111
+ port=0,
112
+ authorization_prompt_message=authorize_prompt(),
113
+ success_message="gfunk is signed in. Close this tab.",
114
+ )
115
+
116
+ token_path.parent.mkdir(parents=True, exist_ok=True)
117
+ token_path.write_text(creds.to_json())
118
+ token_path.chmod(0o600)
119
+ return creds
gfunk/bootstrap.py ADDED
@@ -0,0 +1,183 @@
1
+ """Guided setup for the one thing gfunk cannot ship: your own OAuth client."""
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ Kind = Literal["installed", "web", "service_account", "unknown", "missing", "malformed"]
10
+
11
+ PROJECT_PLACEHOLDER = "YOUR_PROJECT_ID"
12
+
13
+ CONSOLE = "https://console.cloud.google.com"
14
+ _URLS = {
15
+ "sheets_api": f"{CONSOLE}/apis/library/sheets.googleapis.com",
16
+ "drive_api": f"{CONSOLE}/apis/library/drive.googleapis.com",
17
+ "consent": f"{CONSOLE}/auth/overview",
18
+ "clients": f"{CONSOLE}/auth/clients",
19
+ }
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Step:
24
+ number: int
25
+ title: str
26
+ lines: list[str] = field(default_factory=list)
27
+ url: str | None = None
28
+
29
+
30
+ def classify(path: Path) -> Kind:
31
+ """Name the credential family, because every family fails differently."""
32
+ try:
33
+ payload = json.loads(path.read_text())
34
+ except FileNotFoundError:
35
+ return "missing"
36
+ except (json.JSONDecodeError, UnicodeDecodeError):
37
+ return "malformed"
38
+
39
+ if not isinstance(payload, dict):
40
+ return "unknown"
41
+ for marker in ("installed", "web"):
42
+ if marker in payload:
43
+ return marker
44
+ return "service_account" if payload.get("type") == "service_account" else "unknown"
45
+
46
+
47
+ def diagnose(kind: Kind, path: Path) -> str:
48
+ """Turn a wrong credential file into instructions for getting the right one."""
49
+ messages = {
50
+ "missing": (
51
+ f"No OAuth client secrets at {path}. "
52
+ "Run `gfunk mount-up` to be walked through creating one."
53
+ ),
54
+ "malformed": (
55
+ f"{path} is not valid JSON. Re-download the client JSON "
56
+ "from the Google console, or run `gfunk mount-up`."
57
+ ),
58
+ "web": (
59
+ f"{path} is a Web application OAuth client, but gfunk signs in from "
60
+ "your terminal. Create the client again choosing application type "
61
+ "'Desktop app', then run `gfunk mount-up`."
62
+ ),
63
+ "service_account": (
64
+ f"{path} is a service account key, not an OAuth client. Service "
65
+ "accounts have their own identity and see only files explicitly "
66
+ "shared with them; gfunk signs in as you. Run `gfunk mount-up` to "
67
+ "create a Desktop app OAuth client instead."
68
+ ),
69
+ "unknown": (
70
+ f"{path} is not an OAuth client JSON — it has no 'installed' "
71
+ "section. Run `gfunk mount-up` to download the right file."
72
+ ),
73
+ }
74
+ if kind == "installed":
75
+ message = (
76
+ "'installed' is the credential gfunk wants; there is nothing to diagnose."
77
+ )
78
+ raise ValueError(message)
79
+ return messages[kind]
80
+
81
+
82
+ def project_of(path: Path) -> str | None:
83
+ """The client JSON names its own project; asking the user for it is redundant."""
84
+ try:
85
+ payload = json.loads(path.read_text())
86
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
87
+ return None
88
+ if not isinstance(payload, dict):
89
+ return None
90
+ section = payload.get("installed") or payload.get("web") or {}
91
+ project = section.get("project_id") if isinstance(section, dict) else None
92
+ return str(project) if project else None
93
+
94
+
95
+ def console_urls(project: str | None) -> dict[str, str]:
96
+ suffix = f"?project={project}" if project else ""
97
+ return {name: url + suffix for name, url in _URLS.items()}
98
+
99
+
100
+ def walkthrough(project: str | None) -> list[Step]:
101
+ urls = console_urls(project)
102
+ named = project or "your Google Cloud project"
103
+ return [
104
+ Step(
105
+ 1,
106
+ f"Enable the Sheets API in {named}",
107
+ [
108
+ "It should read 'API enabled'. If it offers 'Enable', click that.",
109
+ "Check the project picker up top really says this project.",
110
+ ],
111
+ urls["sheets_api"],
112
+ ),
113
+ Step(
114
+ 2,
115
+ "Enable the Drive API — a separate switch, not covered by step 1",
116
+ [
117
+ "Every Google API is enabled per project, one at a time.",
118
+ "gfunk needs both: Sheets to read rows, Drive to find the files.",
119
+ "Skip this and every `gfunk snoop` fails with 403 accessNotConfigured.",
120
+ ],
121
+ urls["drive_api"],
122
+ ),
123
+ Step(
124
+ 3,
125
+ "Configure the OAuth consent screen (first time only)",
126
+ [
127
+ "Get started -> App name 'gfunk' -> your email -> Audience: External.",
128
+ "Then Audience -> Test users -> + Add users -> your account.",
129
+ "Skipping the test user step causes access_denied later:",
130
+ "in Testing mode only listed test users may sign in.",
131
+ ],
132
+ urls["consent"],
133
+ ),
134
+ Step(
135
+ 4,
136
+ "Create the client",
137
+ [
138
+ "+ Create client -> Application type: Desktop app -> Create.",
139
+ "'Desktop app' matters: a Web client cannot finish this flow.",
140
+ "Download JSON in the dialog that follows.",
141
+ ],
142
+ urls["clients"],
143
+ ),
144
+ Step(
145
+ 5,
146
+ "Install the downloaded JSON",
147
+ [
148
+ "Save it as ~/.config/gfunk/credentials.json:",
149
+ " mkdir -p ~/.config/gfunk && chmod 700 ~/.config/gfunk",
150
+ " mv ~/Downloads/client_secret*.json ~/.config/gfunk/credentials.json",
151
+ " chmod 600 ~/.config/gfunk/credentials.json",
152
+ "Or let gfunk do it:",
153
+ " gfunk mount-up --client-secrets <path/to/downloaded.json>",
154
+ "Then: gfunk mount-up",
155
+ ],
156
+ ),
157
+ ]
158
+
159
+
160
+ def default_download_dirs() -> list[Path]:
161
+ """Downloads, plus the Windows-side one that WSL users actually download into."""
162
+ dirs = [Path.home() / "Downloads"]
163
+ dirs.extend(sorted(Path("/mnt/c/Users").glob("*/Downloads")))
164
+ return dirs
165
+
166
+
167
+ def find_candidates(dirs: list[Path]) -> list[Path]:
168
+ found = [
169
+ path for directory in dirs for path in directory.glob("client_secret*.json")
170
+ ]
171
+ return sorted(found, key=lambda path: path.stat().st_mtime, reverse=True)
172
+
173
+
174
+ def install(source: Path, dest: Path) -> Path:
175
+ kind = classify(source)
176
+ if kind != "installed":
177
+ raise ValueError(diagnose(kind, source))
178
+
179
+ dest.parent.mkdir(parents=True, exist_ok=True)
180
+ dest.parent.chmod(0o700)
181
+ shutil.copyfile(source, dest)
182
+ dest.chmod(0o600)
183
+ return dest
gfunk/browser.py ADDED
@@ -0,0 +1,69 @@
1
+ """Open the OAuth consent page where the human is: a Windows browser, from WSL."""
2
+
3
+ import subprocess
4
+ import sys
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ PROC_VERSION = Path("/proc/version")
10
+ RUNDLL32 = "/mnt/c/Windows/System32/rundll32.exe"
11
+ POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
12
+
13
+
14
+ def _powershell_literal(url: str) -> str:
15
+ """Single-quoted, because -Command re-parses and OAuth URLs are full of '&'."""
16
+ escaped = url.replace("'", "''")
17
+ return f"Start-Process '{escaped}'"
18
+
19
+
20
+ def under_wsl(proc_version: Path = PROC_VERSION) -> bool:
21
+ try:
22
+ return "microsoft" in proc_version.read_text().lower()
23
+ except OSError:
24
+ return False
25
+
26
+
27
+ class WindowsBrowser(webbrowser.BaseBrowser):
28
+ """WSL has no browser; Windows does, and Start-Process reaches it."""
29
+
30
+ def open(self, url: str, new: int = 0, autoraise: bool = True) -> bool: # noqa: ARG002, FBT001, FBT002
31
+ for argv in self.commands(url):
32
+ try:
33
+ done = subprocess.run(argv, capture_output=True, check=False) # noqa: S603
34
+ except OSError:
35
+ continue
36
+ if done.returncode == 0:
37
+ return True
38
+ return False
39
+
40
+ def commands(self, url: str) -> list[list[str]]:
41
+ """rundll32 first: it hands the URL to Windows with no shell to re-parse it."""
42
+ powershell = POWERSHELL if Path(POWERSHELL).exists() else "powershell.exe"
43
+ return [
44
+ [RUNDLL32, "url.dll,FileProtocolHandler", url],
45
+ [powershell, "-NoProfile", "-Command", _powershell_literal(url)],
46
+ ]
47
+
48
+
49
+ def register() -> None:
50
+ """Make webbrowser.open (and anything built on it) reach the Windows side."""
51
+ if not under_wsl():
52
+ return
53
+ webbrowser.register("gfunk-windows", None, WindowsBrowser(), preferred=True)
54
+
55
+
56
+ def hyperlink(text: str, url: str, *, supported: bool | None = None) -> str:
57
+ """OSC 8: one clickable link, immune to however the terminal wraps the URL."""
58
+ if supported is None:
59
+ supported = sys.stdout.isatty()
60
+ if not supported:
61
+ return url
62
+ return f"\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\"
63
+
64
+
65
+ def open_in_browser(item: dict[str, Any]) -> None:
66
+ link = item.get("webViewLink") or f"https://drive.google.com/open?id={item['id']}"
67
+ register()
68
+ if not webbrowser.open(link):
69
+ print(f"Could not open a browser. The link is:\n {link}", file=sys.stderr)
gfunk/cache.py ADDED
@@ -0,0 +1,123 @@
1
+ """Local store for bulk Workspace reads.
2
+
3
+ SQLite rather than Parquet: it is in the stdlib, it is queryable without a
4
+ second engine, and it is one file that can be chmod-ed. The file holds real
5
+ Workspace content, so it is created 0600 and lives outside the repo.
6
+ """
7
+
8
+ import json
9
+ import sqlite3
10
+ from collections.abc import Generator, Iterable
11
+ from contextlib import contextmanager
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ DEFAULT_CACHE_PATH = Path.home() / ".local" / "share" / "gfunk" / "cache.db"
17
+
18
+ SCHEMA = """
19
+ CREATE TABLE IF NOT EXISTS records (
20
+ service TEXT NOT NULL,
21
+ kind TEXT NOT NULL,
22
+ record_id TEXT NOT NULL,
23
+ fetched_at TEXT NOT NULL,
24
+ payload TEXT NOT NULL,
25
+ PRIMARY KEY (service, kind, record_id)
26
+ );
27
+ """
28
+
29
+
30
+ class Cache:
31
+ """Records keyed by (service, kind, record_id), payloads as JSON text."""
32
+
33
+ def __init__(self, path: Path = DEFAULT_CACHE_PATH) -> None:
34
+ self.path = path
35
+ self.path.parent.mkdir(parents=True, exist_ok=True)
36
+ # Create the file before writing to it so the 0600 is in place first;
37
+ # sqlite would otherwise create it 0644 and the content would sit
38
+ # world-readable for the width of the connect.
39
+ self.path.touch(mode=0o600, exist_ok=True)
40
+ self.path.chmod(0o600)
41
+ with self._connect() as conn:
42
+ conn.executescript(SCHEMA)
43
+
44
+ @contextmanager
45
+ def _connect(self) -> Generator[sqlite3.Connection]:
46
+ conn = sqlite3.connect(self.path)
47
+ try:
48
+ yield conn
49
+ conn.commit()
50
+ except BaseException:
51
+ conn.rollback()
52
+ raise
53
+ finally:
54
+ conn.close()
55
+
56
+ def put(self, service: str, kind: str, record_id: str, payload: Any) -> None:
57
+ self.put_many(service, kind, [(record_id, payload)])
58
+
59
+ def put_many(
60
+ self, service: str, kind: str, records: Iterable[tuple[str, Any]]
61
+ ) -> None:
62
+ now = datetime.now(UTC).isoformat()
63
+ rows = [
64
+ (service, kind, record_id, now, json.dumps(payload))
65
+ for record_id, payload in records
66
+ ]
67
+ with self._connect() as conn:
68
+ conn.executemany(
69
+ """
70
+ INSERT INTO records (service, kind, record_id, fetched_at, payload)
71
+ VALUES (?, ?, ?, ?, ?)
72
+ ON CONFLICT (service, kind, record_id) DO UPDATE SET
73
+ fetched_at = excluded.fetched_at,
74
+ payload = excluded.payload
75
+ """,
76
+ rows,
77
+ )
78
+
79
+ def get(self, service: str, kind: str, record_id: str) -> Any | None:
80
+ with self._connect() as conn:
81
+ row = conn.execute(
82
+ """
83
+ SELECT payload FROM records
84
+ WHERE service = ? AND kind = ? AND record_id = ?
85
+ """,
86
+ (service, kind, record_id),
87
+ ).fetchone()
88
+ return json.loads(row[0]) if row else None
89
+
90
+ def fetched_at(self, service: str, kind: str, record_id: str) -> str | None:
91
+ with self._connect() as conn:
92
+ row = conn.execute(
93
+ """
94
+ SELECT fetched_at FROM records
95
+ WHERE service = ? AND kind = ? AND record_id = ?
96
+ """,
97
+ (service, kind, record_id),
98
+ ).fetchone()
99
+ return str(row[0]) if row else None
100
+
101
+ def records(self, service: str, kind: str, limit: int | None = None) -> list[Any]:
102
+ sql = """
103
+ SELECT payload FROM records
104
+ WHERE service = ? AND kind = ?
105
+ ORDER BY record_id
106
+ """
107
+ params: tuple[Any, ...] = (service, kind)
108
+ if limit is not None:
109
+ sql += " LIMIT ?"
110
+ params = (*params, limit)
111
+ with self._connect() as conn:
112
+ rows = conn.execute(sql, params).fetchall()
113
+ return [json.loads(row[0]) for row in rows]
114
+
115
+ def clear(self, service: str, kind: str | None = None) -> None:
116
+ with self._connect() as conn:
117
+ if kind is None:
118
+ conn.execute("DELETE FROM records WHERE service = ?", (service,))
119
+ else:
120
+ conn.execute(
121
+ "DELETE FROM records WHERE service = ? AND kind = ?",
122
+ (service, kind),
123
+ )