folderlake 0.2.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.
folderlake/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """FolderLake — a local catalog for a folder of mixed files."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,307 @@
1
+ """FolderLake home server — the web-app front door.
2
+
3
+ ``folderlake app`` starts this server and opens it immediately: no
4
+ folder picker, no scan before the page appears. The home page lists
5
+ every catalog built so far (from the SQLite registry), starts new
6
+ scans with live progress, refreshes or deletes old ones, and holds
7
+ the LLM settings. Opening a catalog spins up the ordinary
8
+ single-catalog viewer server (in a thread, on its own port) with a
9
+ "← FolderLake Home" link injected into its header.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import urllib.parse
20
+ import uuid
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+ from pathlib import Path
23
+
24
+ from .catalog import CATALOG_NAME, scan
25
+ from .hierarchy import build_hierarchy
26
+ from .registry import Registry, catalog_id_for
27
+ from .relations import build_relations
28
+ from .server import create_server
29
+ from .catalog import try_build_fts
30
+
31
+ HOME_PATH = Path(__file__).with_name("home.html")
32
+
33
+
34
+ class App:
35
+ def __init__(self, host: str = "127.0.0.1", port: int = 8770,
36
+ registry: Registry | None = None):
37
+ self.host = host
38
+ self.registry = registry or Registry()
39
+ self.jobs: dict[str, dict] = {}
40
+ self._job_lock = threading.Lock()
41
+ self.viewers: dict[str, tuple[ThreadingHTTPServer, int]] = {}
42
+ handler = type("AppHandler", (_Handler,), {"app": self})
43
+ try:
44
+ self.server = ThreadingHTTPServer((host, port), handler)
45
+ except OSError:
46
+ self.server = ThreadingHTTPServer((host, 0), handler)
47
+ self.port = self.server.server_address[1]
48
+ self.home_url = f"http://{host}:{self.port}/"
49
+
50
+ # -- jobs ---------------------------------------------------------
51
+ def running_job(self) -> dict | None:
52
+ with self._job_lock:
53
+ for j in self.jobs.values():
54
+ if j["status"] == "running":
55
+ return j
56
+ return None
57
+
58
+ def start_scan(self, folder: Path, use_llm: bool) -> dict:
59
+ if self.running_job() is not None:
60
+ raise RuntimeError("a scan is already running")
61
+ job = {
62
+ "id": uuid.uuid4().hex[:12], "status": "running",
63
+ "phase": "starting", "done": 0, "total": 0, "current": "",
64
+ "message": "", "catalog_id": None, "path": str(folder),
65
+ }
66
+ with self._job_lock:
67
+ self.jobs[job["id"]] = job
68
+ threading.Thread(target=self._run_scan, args=(job, folder, use_llm),
69
+ daemon=True).start()
70
+ return job
71
+
72
+ def _apply_llm_env(self, use_llm: bool) -> dict:
73
+ """Snapshot the settings a scan runs with; when the LLM is on,
74
+ also publish them to the env the LLM provider layer reads."""
75
+ s = self.registry.get_settings()
76
+ snapshot = {
77
+ "use_llm": bool(use_llm),
78
+ "llm_provider": s["llm_provider"],
79
+ "llm_model": s["llm_model"],
80
+ }
81
+ if use_llm:
82
+ os.environ["FOLDERLAKE_LLM_PROVIDER"] = s["llm_provider"]
83
+ if s["llm_model"]:
84
+ os.environ["FOLDERLAKE_LLM_MODEL"] = s["llm_model"]
85
+ if s["llm_base_url"]:
86
+ os.environ["FOLDERLAKE_LLM_BASE_URL"] = s["llm_base_url"]
87
+ key = self.registry.get_api_key()
88
+ if key:
89
+ os.environ["FOLDERLAKE_LLM_API_KEY"] = key
90
+ return snapshot
91
+
92
+ def _run_scan(self, job: dict, folder: Path, use_llm: bool) -> None:
93
+ cid = catalog_id_for(folder)
94
+ was_open = cid in self.viewers
95
+ try:
96
+ if was_open:
97
+ self._stop_viewer(cid) # DuckDB allows one writer
98
+ snapshot = self._apply_llm_env(use_llm)
99
+ db_path = folder / CATALOG_NAME
100
+
101
+ def progress(done, total, name):
102
+ job.update(phase="scanning", done=done, total=total,
103
+ current=name)
104
+
105
+ stats = scan(folder, db_path, use_llm=use_llm,
106
+ progress=progress)
107
+ job.update(phase="linking files", current="")
108
+ build_relations(db_path)
109
+ build_hierarchy(db_path)
110
+ try_build_fts(db_path)
111
+ rec = self.registry.upsert_catalog(
112
+ folder, stats["seen"], db_path.stat().st_size, snapshot)
113
+ job.update(status="done", phase="done",
114
+ catalog_id=rec["id"])
115
+ except Exception as exc: # surfaced on the home page, not a crash
116
+ job.update(status="error", message=str(exc))
117
+ finally:
118
+ if was_open and job["status"] == "done":
119
+ self.open_catalog(cid)
120
+
121
+ # -- catalog viewers ------------------------------------------------
122
+ def open_catalog(self, catalog_id: str) -> str:
123
+ rec = self.registry.get_catalog(catalog_id)
124
+ if rec is None:
125
+ raise KeyError("no such catalog")
126
+ if catalog_id in self.viewers:
127
+ _, port = self.viewers[catalog_id]
128
+ self.registry.touch_opened(catalog_id)
129
+ return f"http://{self.host}:{port}/"
130
+ folder = Path(rec["path"])
131
+ db_path = folder / CATALOG_NAME
132
+ if not db_path.exists():
133
+ raise KeyError("catalog file is missing — refresh to rebuild it")
134
+ server = create_server(db_path, folder, self.host, 0,
135
+ home_url=self.home_url)
136
+ port = server.server_address[1]
137
+ threading.Thread(target=server.serve_forever, daemon=True).start()
138
+ self.viewers[catalog_id] = (server, port)
139
+ self.registry.touch_opened(catalog_id)
140
+ return f"http://{self.host}:{port}/"
141
+
142
+ def _stop_viewer(self, catalog_id: str) -> None:
143
+ entry = self.viewers.pop(catalog_id, None)
144
+ if entry is not None:
145
+ server, _ = entry
146
+ server.shutdown()
147
+ server.server_close()
148
+
149
+ def delete_catalog(self, catalog_id: str) -> dict:
150
+ self._stop_viewer(catalog_id)
151
+ rec = self.registry.delete_catalog(catalog_id)
152
+ if rec is None:
153
+ raise KeyError("no such catalog")
154
+ db_path = Path(rec["path"]) / CATALOG_NAME
155
+ for p in (db_path, Path(str(db_path) + ".wal")):
156
+ try:
157
+ p.unlink()
158
+ except FileNotFoundError:
159
+ pass
160
+ return rec
161
+
162
+ # -- folder dialog (subprocess: tkinter needs its own main thread) --
163
+ @staticmethod
164
+ def browse_folder() -> str:
165
+ code = (
166
+ "import tkinter\n"
167
+ "from tkinter import filedialog\n"
168
+ "root = tkinter.Tk(); root.withdraw()\n"
169
+ "print(filedialog.askdirectory("
170
+ "title='Choose a folder for FolderLake'))\n"
171
+ "root.destroy()\n"
172
+ )
173
+ try:
174
+ out = subprocess.run(
175
+ [sys.executable, "-c", code], capture_output=True,
176
+ text=True, timeout=600)
177
+ return out.stdout.strip().splitlines()[-1] if out.stdout.strip() else ""
178
+ except Exception:
179
+ return ""
180
+
181
+
182
+ class _Handler(BaseHTTPRequestHandler):
183
+ app: App
184
+
185
+ def log_message(self, *args):
186
+ pass
187
+
188
+ def _send_json(self, payload, status: int = 200) -> None:
189
+ body = json.dumps(payload, ensure_ascii=False).encode()
190
+ self.send_response(status)
191
+ self.send_header("Content-Type", "application/json; charset=utf-8")
192
+ self.send_header("Content-Length", str(len(body)))
193
+ self.end_headers()
194
+ self.wfile.write(body)
195
+
196
+ def _send_html(self, text: str) -> None:
197
+ body = text.encode()
198
+ self.send_response(200)
199
+ self.send_header("Content-Type", "text/html; charset=utf-8")
200
+ self.send_header("Content-Length", str(len(body)))
201
+ self.end_headers()
202
+ self.wfile.write(body)
203
+
204
+ def _body(self) -> dict:
205
+ n = int(self.headers.get("Content-Length") or 0)
206
+ if not n:
207
+ return {}
208
+ try:
209
+ return json.loads(self.rfile.read(n) or b"{}")
210
+ except Exception:
211
+ return {}
212
+
213
+ def do_GET(self): # noqa: N802 - stdlib name
214
+ parsed = urllib.parse.urlparse(self.path)
215
+ app = self.app
216
+ if parsed.path == "/":
217
+ self._send_html(HOME_PATH.read_text(encoding="utf-8"))
218
+ return
219
+ if parsed.path == "/api/catalogs":
220
+ self._send_json(app.registry.list_catalogs())
221
+ return
222
+ if parsed.path == "/api/settings":
223
+ self._send_json(app.registry.get_settings())
224
+ return
225
+ if parsed.path.startswith("/api/jobs/"):
226
+ job = app.jobs.get(parsed.path[len("/api/jobs/"):])
227
+ if job is None:
228
+ self._send_json({"error": "no such job"}, 404)
229
+ else:
230
+ self._send_json(job)
231
+ return
232
+ self._send_json({"error": "not found"}, 404)
233
+
234
+ def do_POST(self): # noqa: N802 - stdlib name
235
+ parsed = urllib.parse.urlparse(self.path)
236
+ app = self.app
237
+ body = self._body()
238
+ try:
239
+ if parsed.path == "/api/scan":
240
+ folder = Path(str(body.get("path", ""))).expanduser()
241
+ if not folder.is_dir():
242
+ self._send_json(
243
+ {"error": f"not a folder: {folder}"}, 400)
244
+ return
245
+ use_llm = bool(body.get("use_llm"))
246
+ job = app.start_scan(folder.resolve(), use_llm)
247
+ self._send_json({"id": job["id"]}, 202)
248
+ return
249
+ if parsed.path == "/api/browse":
250
+ self._send_json({"path": app.browse_folder()})
251
+ return
252
+ if parsed.path == "/api/settings":
253
+ self._send_json(app.registry.save_settings(body))
254
+ return
255
+ if parsed.path.startswith("/api/catalogs/") and \
256
+ parsed.path.endswith("/open"):
257
+ cid = parsed.path.split("/")[3]
258
+ self._send_json({"url": app.open_catalog(cid)})
259
+ return
260
+ if parsed.path.startswith("/api/catalogs/") and \
261
+ parsed.path.endswith("/refresh"):
262
+ cid = parsed.path.split("/")[3]
263
+ rec = app.registry.get_catalog(cid)
264
+ if rec is None:
265
+ self._send_json({"error": "no such catalog"}, 404)
266
+ return
267
+ snap = rec["settings"] or {}
268
+ job = app.start_scan(
269
+ Path(rec["path"]), bool(snap.get("use_llm")))
270
+ self._send_json({"id": job["id"]}, 202)
271
+ return
272
+ except KeyError as exc:
273
+ self._send_json({"error": str(exc)}, 404)
274
+ return
275
+ except RuntimeError as exc:
276
+ self._send_json({"error": str(exc)}, 409)
277
+ return
278
+ self._send_json({"error": "not found"}, 404)
279
+
280
+ def do_DELETE(self): # noqa: N802 - stdlib name
281
+ parsed = urllib.parse.urlparse(self.path)
282
+ if parsed.path.startswith("/api/catalogs/"):
283
+ cid = parsed.path.split("/")[3]
284
+ try:
285
+ self.app.delete_catalog(cid)
286
+ self._send_json({"deleted": cid})
287
+ return
288
+ except KeyError:
289
+ self._send_json({"error": "no such catalog"}, 404)
290
+ return
291
+ self._send_json({"error": "not found"}, 404)
292
+
293
+
294
+ def run_app(host: str = "127.0.0.1", port: int = 8770,
295
+ open_browser: bool = True) -> int:
296
+ app = App(host, port)
297
+ print(f"FolderLake home: {app.home_url}", flush=True)
298
+ print("Press Ctrl-C to stop.", flush=True)
299
+ if open_browser:
300
+ import webbrowser
301
+
302
+ webbrowser.open(app.home_url)
303
+ try:
304
+ app.server.serve_forever()
305
+ except KeyboardInterrupt:
306
+ print("\nStopped.")
307
+ return 0