duckpad 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.
duckpad/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Duckpad: local SQL pipelines with execution-bound lineage."""
2
+
3
+ __version__ = "0.1.0"
duckpad/app.py ADDED
@@ -0,0 +1,353 @@
1
+ """Authenticated FastAPI endpoints for the local workspace."""
2
+
3
+ import copy
4
+ import json
5
+ import os
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+ from urllib.parse import urlparse
10
+
11
+ from fastapi import FastAPI, File, Request, UploadFile
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from pydantic import BaseModel
15
+
16
+ from .auth import Auth
17
+ from .catalog import Catalog
18
+ from .components import group, import_definition
19
+ from .engine import Engine
20
+ from .example import create_example
21
+ from .lineage import Lineage
22
+ from .projects import Projects, bundle_to_files
23
+ from .resources import resource_usage
24
+ from .store import Store
25
+ from .templates import parse_template
26
+ from .util import Problem, clean, uid
27
+
28
+
29
+ class Login(BaseModel):
30
+ username: str = "admin"
31
+ password: str
32
+
33
+
34
+ def create_app(home: Path, watch=True):
35
+ store = Store(home)
36
+ auth, projects, catalog = Auth(store), Projects(store), Catalog(store)
37
+ engine = Engine(store, projects, catalog)
38
+ lineage = Lineage(engine)
39
+ projects.on_change = engine.invalidate
40
+ catalog.on_change = engine.invalidate
41
+
42
+ @asynccontextmanager
43
+ async def lifespan(app):
44
+ if watch:
45
+ projects.start_watcher()
46
+ yield
47
+ projects.stop()
48
+ engine.close()
49
+ store.close()
50
+
51
+ app = FastAPI(
52
+ title="Duckpad", version="0.1.0", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None
53
+ )
54
+ app.state.services = (store, projects, catalog, engine)
55
+ app.state.auth = auth
56
+
57
+ @app.exception_handler(Problem)
58
+ async def problem_handler(request, error):
59
+ return JSONResponse({"error": str(error), "detail": clean(error.detail)}, status_code=error.status)
60
+
61
+ @app.middleware("http")
62
+ async def authentication(request: Request, call_next):
63
+ try:
64
+ if request.url.path.startswith("/api/") and request.url.path != "/api/login":
65
+ token = request.cookies.get("duckpad_session") or request.headers.get(
66
+ "authorization", ""
67
+ ).removeprefix("Bearer ")
68
+ auth.check(token)
69
+ if request.method not in ("GET", "HEAD", "OPTIONS"):
70
+ origin = request.headers.get("origin")
71
+ if origin and urlparse(origin).netloc != request.headers.get("host"):
72
+ raise Problem("Cross-origin writes are not allowed", 403)
73
+ response = await call_next(request)
74
+ response.headers["X-Content-Type-Options"] = "nosniff"
75
+ response.headers["Referrer-Policy"] = "same-origin"
76
+ response.headers["X-Frame-Options"] = "DENY"
77
+ if request.url.path.startswith("/api/"):
78
+ response.headers["Cache-Control"] = "no-store"
79
+ return response
80
+ except Problem as error:
81
+ return JSONResponse(
82
+ {"error": str(error), "detail": clean(error.detail)}, status_code=error.status
83
+ )
84
+
85
+ @app.post("/api/login")
86
+ def login(data: Login, request: Request):
87
+ token = auth.login(data.username, data.password, request.client.host if request.client else "local")
88
+ response = JSONResponse({"username": "admin"})
89
+ response.set_cookie(
90
+ "duckpad_session",
91
+ token,
92
+ httponly=True,
93
+ samesite="strict",
94
+ max_age=604800,
95
+ secure=os.environ.get("DUCKPAD_SECURE_COOKIES") == "1" or request.url.scheme == "https",
96
+ )
97
+ return response
98
+
99
+ @app.post("/api/logout")
100
+ def logout(request: Request):
101
+ auth.logout(request.cookies.get("duckpad_session"))
102
+ response = JSONResponse({"ok": True})
103
+ response.delete_cookie("duckpad_session")
104
+ return response
105
+
106
+ @app.get("/api/session")
107
+ def session():
108
+ return {"username": "admin", "version": "0.1.0"}
109
+
110
+ @app.get("/api/resources")
111
+ def resources():
112
+ return resource_usage()
113
+
114
+ @app.get("/api/workspace")
115
+ def workspace():
116
+ return {
117
+ "projects": store.all("project"),
118
+ "worktrees": store.all("worktree"),
119
+ "catalog": catalog.public(),
120
+ "identity": store.get(
121
+ "settings", "identity", {"name": "Duckpad user", "email": "admin@duckpad.local"}
122
+ ),
123
+ }
124
+
125
+ @app.put("/api/identity")
126
+ def identity(data: dict):
127
+ if not data.get("name") or not data.get("email"):
128
+ raise Problem("Name and email are required")
129
+ return store.put("settings", "identity", {"name": data["name"], "email": data["email"]})
130
+
131
+ @app.post("/api/projects")
132
+ def create_project(data: dict):
133
+ return projects.create(str(data.get("name", "Untitled project")))
134
+
135
+ @app.post("/api/example")
136
+ def example():
137
+ return create_example(projects, catalog)
138
+
139
+ @app.get("/api/worktrees/{worktree}")
140
+ def get_project(worktree: str):
141
+ result = projects.snapshot(worktree, committed=True)
142
+ parsed = {}
143
+ for name, value in (result["bundle"] or {}).get("templates", {}).items():
144
+ try:
145
+ parsed[name] = parse_template(value)
146
+ except Problem as error:
147
+ parsed[name] = {"error": str(error)}
148
+ result["parsed_templates"] = parsed
149
+ return result
150
+
151
+ @app.put("/api/worktrees/{worktree}")
152
+ def save_project(worktree: str, data: dict):
153
+ proposal = data["files"] if "files" in data else bundle_to_files(data["bundle"])
154
+ return projects.write(worktree, data["revision"], proposal)
155
+
156
+ @app.post("/api/conflicts/{conflict}/resolve")
157
+ def resolve_conflict(conflict: str, data: dict):
158
+ return projects.resolve(conflict, data["choice"], data.get("files"))
159
+
160
+ @app.get("/api/worktrees/{worktree}/history")
161
+ def history(worktree: str):
162
+ return projects.history(worktree)
163
+
164
+ @app.get("/api/worktrees/{worktree}/diff/{commit}")
165
+ def diff(worktree: str, commit: str):
166
+ return {"diff": projects.diff(worktree, commit)}
167
+
168
+ @app.post("/api/worktrees/{worktree}/branches")
169
+ def branch(worktree: str, data: dict):
170
+ return projects.branch(worktree, data["name"], data.get("revision"))
171
+
172
+ @app.get("/api/worktrees/{worktree}/merge")
173
+ def compare(worktree: str):
174
+ return projects.merge_preview(worktree)
175
+
176
+ @app.post("/api/worktrees/{worktree}/merge")
177
+ def merge(worktree: str, data: dict):
178
+ return projects.merge(worktree, data["source_revision"], data["target_revision"], data["files"])
179
+
180
+ @app.post("/api/worktrees/{worktree}/group")
181
+ def group_nodes(worktree: str, data: dict):
182
+ snapshot = projects.snapshot(worktree)
183
+ if data["revision"] != snapshot["revision"]:
184
+ raise Problem("Reload the graph before grouping", 409)
185
+ bundle, selected = group(
186
+ snapshot["bundle"], data["nodes"], data["name"], data.get("parameters"), data.get("parent")
187
+ )
188
+ result = projects.write(worktree, data["revision"], bundle_to_files(bundle))
189
+ result["selected"] = selected
190
+ return result
191
+
192
+ @app.get("/api/library")
193
+ def library():
194
+ result = []
195
+ for project in store.all("project"):
196
+ snapshot = projects.snapshot(project["main"])
197
+ if snapshot["bundle"]:
198
+ for kind in ("templates", "components"):
199
+ for identity, value in snapshot["bundle"][kind].items():
200
+ try:
201
+ label = parse_template(value)["label"] if kind == "templates" else value["name"]
202
+ result.append(
203
+ {
204
+ "project": project["id"],
205
+ "project_name": project["name"],
206
+ "kind": kind,
207
+ "id": identity,
208
+ "name": label,
209
+ "worktree": project["main"],
210
+ "commit": snapshot["commit"],
211
+ }
212
+ )
213
+ except Problem:
214
+ pass
215
+ return result
216
+
217
+ @app.post("/api/worktrees/{worktree}/import")
218
+ def import_item(worktree: str, data: dict):
219
+ snapshot = projects.snapshot(worktree)
220
+ source = projects.snapshot(data["source_worktree"])
221
+ bundle, key = import_definition(
222
+ snapshot["bundle"],
223
+ source["bundle"],
224
+ data["kind"],
225
+ data["id"],
226
+ {"project": source["worktree"]["project"], "commit": source["commit"]},
227
+ )
228
+ result = projects.write(worktree, data["revision"], bundle_to_files(bundle))
229
+ result["imported"] = key
230
+ return result
231
+
232
+ @app.get("/api/catalog")
233
+ def get_catalog():
234
+ return catalog.public()
235
+
236
+ @app.get("/api/worktrees/{worktree}/generations/{identity}")
237
+ def generation(worktree: str, identity: str, environment: str = "dev"):
238
+ context = engine.context(worktree, environment)
239
+ with context.lock:
240
+ dataset = context.datasets.get(identity)
241
+ return {"valid": bool(dataset and dataset.status == "valid")}
242
+
243
+ @app.put("/api/catalog/{kind}")
244
+ def save_catalog(kind: str, data: dict):
245
+ if kind not in ("connector", "connection"):
246
+ raise Problem("Unknown catalog entity")
247
+ catalog.save(kind, copy.deepcopy(data))
248
+ return catalog.public()
249
+
250
+ @app.delete("/api/catalog/{kind}/{identity}")
251
+ def delete_catalog(kind: str, identity: str):
252
+ if kind not in ("connector", "connection"):
253
+ raise Problem("Unknown catalog entity")
254
+ store.delete(kind, identity)
255
+ engine.invalidate()
256
+ return catalog.public()
257
+
258
+ @app.put("/api/environments")
259
+ def environments(data: list[str]):
260
+ if not data or any(not value.strip() for value in data):
261
+ raise Problem("Provide at least one named environment")
262
+ store.put("settings", "environments", list(dict.fromkeys(data)))
263
+ return catalog.public()
264
+
265
+ @app.post("/api/uploads")
266
+ async def upload(file: Annotated[UploadFile, File()]):
267
+ folder = store.home / "resources"
268
+ folder.mkdir(exist_ok=True)
269
+ name = Path(file.filename or "data.csv").name
270
+ path = folder / (uid() + "_" + name)
271
+ with path.open("wb") as stream:
272
+ while chunk := await file.read(1024 * 1024):
273
+ stream.write(chunk)
274
+ return {"path": str(path), "name": name}
275
+
276
+ @app.get("/api/worktrees/{worktree}/status")
277
+ def status(worktree: str, environment: str = "dev"):
278
+ return engine.statuses(worktree, environment)
279
+
280
+ @app.post("/api/worktrees/{worktree}/runs")
281
+ def launch(worktree: str, data: dict):
282
+ return engine.launch(
283
+ worktree,
284
+ data.get("environment", "dev"),
285
+ data.get("node"),
286
+ "ui" if data.get("entry_point") == "ui" else "api",
287
+ )
288
+
289
+ @app.get("/api/runs")
290
+ def runs(worktree: str | None = None):
291
+ return list(reversed([r for r in store.all("run") if not worktree or r["worktree"] == worktree]))
292
+
293
+ @app.get("/api/runs/{run}")
294
+ def get_run(run: str):
295
+ record = store.get("run", run)
296
+ if not record:
297
+ raise Problem("Execution not found", 404)
298
+ return record
299
+
300
+ @app.post("/api/runs/{run}/cancel")
301
+ def cancel(run: str):
302
+ return engine.cancel(run)
303
+
304
+ @app.get("/api/worktrees/{worktree}/compile/{node}")
305
+ def compile_sql(worktree: str, node: str, environment: str = "dev"):
306
+ return engine.compile(worktree, environment, node)
307
+
308
+ @app.get("/api/worktrees/{worktree}/preview/{node}")
309
+ def preview(
310
+ worktree: str,
311
+ node: str,
312
+ environment: str = "dev",
313
+ port: str = "result",
314
+ limit: int = 100,
315
+ offset: int = 0,
316
+ sort: str | None = None,
317
+ descending: bool = False,
318
+ filters: str = "{}",
319
+ ):
320
+ try:
321
+ filter_values = json.loads(filters)
322
+ except ValueError as error:
323
+ raise Problem("Invalid preview filters") from error
324
+ return engine.preview(
325
+ worktree, environment, node, port, limit, offset, sort, descending, filter_values
326
+ )
327
+
328
+ @app.post("/api/worktrees/{worktree}/lineage")
329
+ def explain(worktree: str, data: dict):
330
+ return lineage.explain(
331
+ worktree,
332
+ data.get("environment", "dev"),
333
+ data["dataset"],
334
+ data["row"],
335
+ data["column"],
336
+ data.get("offset", 0),
337
+ data.get("group"),
338
+ data.get("context", False),
339
+ )
340
+
341
+ static = Path(__file__).parent / "static"
342
+ if (static / "assets").exists():
343
+ app.mount("/assets", StaticFiles(directory=static / "assets"), name="assets")
344
+
345
+ @app.get("/{path:path}")
346
+ def frontend(path: str):
347
+ if (static / "index.html").exists():
348
+ return FileResponse(static / "index.html")
349
+ return JSONResponse(
350
+ {"message": "Build the frontend: cd frontend && npm install && npm run build"}, status_code=503
351
+ )
352
+
353
+ return app
duckpad/auth.py ADDED
@@ -0,0 +1,47 @@
1
+ """Persistent salted credentials and expiring, server-side sessions."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import secrets
6
+ import time
7
+
8
+ from .util import Problem
9
+
10
+
11
+ class Auth:
12
+ def __init__(self, store):
13
+ self.store = store
14
+ self.initial_password = None
15
+ if not store.get("auth", "admin"):
16
+ self.initial_password = secrets.token_urlsafe(18)
17
+ salt = secrets.token_hex(32)
18
+ store.put("auth", "admin", {"salt": salt, "hash": self.hash(self.initial_password, salt)})
19
+ self.attempts = {}
20
+
21
+ @staticmethod
22
+ def hash(password, salt):
23
+ return hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1).hex()
24
+
25
+ def login(self, username, password, peer="local"):
26
+ recent = [t for t in self.attempts.get(peer, []) if t > time.time() - 60]
27
+ self.attempts[peer] = recent
28
+ if len(recent) >= 12:
29
+ raise Problem("Too many login attempts. Try again in a minute.", 429)
30
+ record = self.store.get("auth", "admin")
31
+ valid = hmac.compare_digest(self.hash(password, record["salt"]), record["hash"])
32
+ if username != "admin" or not valid:
33
+ recent.append(time.time())
34
+ raise Problem("Invalid username or password", 401)
35
+ token = secrets.token_urlsafe(32)
36
+ self.store.put(
37
+ "session", hashlib.sha256(token.encode()).hexdigest(), {"expires": time.time() + 86400 * 7}
38
+ )
39
+ return token
40
+
41
+ def check(self, token):
42
+ session = self.store.get("session", hashlib.sha256((token or "").encode()).hexdigest())
43
+ if not session or session["expires"] < time.time():
44
+ raise Problem("Authentication required", 401)
45
+
46
+ def logout(self, token):
47
+ self.store.delete("session", hashlib.sha256((token or "").encode()).hexdigest())