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 +3 -0
- gfunk/__main__.py +4 -0
- gfunk/auth.py +119 -0
- gfunk/bootstrap.py +183 -0
- gfunk/browser.py +69 -0
- gfunk/cache.py +123 -0
- gfunk/cli.py +2089 -0
- gfunk/demo_data.py +693 -0
- gfunk/dubs.py +110 -0
- gfunk/dubs_tui.py +316 -0
- gfunk/errors.py +55 -0
- gfunk/gmail.py +286 -0
- gfunk/grind_tui.py +208 -0
- gfunk/holla_tui.py +679 -0
- gfunk/mcp_config.py +98 -0
- gfunk/mothership.py +129 -0
- gfunk/mothership_config.py +64 -0
- gfunk/regulate.py +113 -0
- gfunk/regulate_tui.py +331 -0
- gfunk/snoop_tui.py +559 -0
- gfunk/vibe.py +114 -0
- gfunk/workspace.py +753 -0
- gfunk-0.1.0.dist-info/METADATA +13 -0
- gfunk-0.1.0.dist-info/RECORD +27 -0
- gfunk-0.1.0.dist-info/WHEEL +4 -0
- gfunk-0.1.0.dist-info/entry_points.txt +2 -0
- gfunk-0.1.0.dist-info/licenses/LICENSE +21 -0
gfunk/__init__.py
ADDED
gfunk/__main__.py
ADDED
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
|
+
)
|