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 +3 -0
- duckpad/app.py +353 -0
- duckpad/auth.py +47 -0
- duckpad/catalog.py +278 -0
- duckpad/cli.py +57 -0
- duckpad/components.py +308 -0
- duckpad/engine.py +663 -0
- duckpad/example.py +282 -0
- duckpad/lineage.py +761 -0
- duckpad/materialize.py +126 -0
- duckpad/projects.py +522 -0
- duckpad/resources.py +103 -0
- duckpad/static/assets/Editor-DyX1CsEw.css +1 -0
- duckpad/static/assets/Editor-t9xBSzyU.js +679 -0
- duckpad/static/assets/editor.worker-BCzxt1at.js +12 -0
- duckpad/static/assets/index-CHSQY8z7.css +1 -0
- duckpad/static/assets/index-GIKY3NsH.js +325 -0
- duckpad/static/assets/json.worker-B7c_PmGb.js +49 -0
- duckpad/static/assets/jsonMode-BbQxTeWp.js +15 -0
- duckpad/static/assets/sql-CCSDG5nI.js +6 -0
- duckpad/static/index.html +3 -0
- duckpad/store.py +48 -0
- duckpad/templates.py +375 -0
- duckpad/util.py +82 -0
- duckpad-0.1.0.dist-info/METADATA +123 -0
- duckpad-0.1.0.dist-info/RECORD +29 -0
- duckpad-0.1.0.dist-info/WHEEL +4 -0
- duckpad-0.1.0.dist-info/entry_points.txt +2 -0
- duckpad-0.1.0.dist-info/licenses/LICENSE +21 -0
duckpad/__init__.py
ADDED
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())
|