databricks-explore 0.5.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.
@@ -0,0 +1,333 @@
1
+ """Canvas files under data/canvases/<name>.json, format versions 1 and 2.
2
+
3
+ The pydantic models mirror contracts/canvas.schema.json. Checks the schema cannot express
4
+ (unique window titles and ids, links and connectors that point at existing ids) run in check_references().
5
+ Validated bodies are stored exactly as received, so save followed by load returns the same object.
6
+ Version 2 adds the sketch node types and a top-level `edges` list of user-drawn connectors.
7
+ Each canvas also has a revision number (a counter in data/canvas_history/<name>/meta.json, never inside the canvas file)
8
+ that every save bumps, and agent writes keep the replaced file as a snapshot so they can be undone.
9
+ """
10
+ import json
11
+ import os
12
+ import threading
13
+ from typing import Annotated, Any, Literal, Optional
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, ValidationError, model_validator
16
+ from pydantic_core import PydanticCustomError
17
+
18
+ from . import dbx
19
+
20
+ CANVASES = dbx.DATA / "canvases"
21
+ CANVASES.mkdir(parents=True, exist_ok=True)
22
+
23
+ NAME_PATTERN = r"^[A-Za-z0-9_.-]+$"
24
+ KEEP_SNAPSHOTS = 20 # per canvas
25
+ LOCK = threading.RLock() # one writer at a time, so an agent's read-modify-write cannot interleave with a save
26
+
27
+
28
+ class CanvasInvalid(ValueError):
29
+ """Validation failed. str(e) is '<path>: <reason>' (route returns it as HTTP 400)."""
30
+
31
+
32
+ class CanvasNotFound(LookupError):
33
+ """No stored canvas with that name (route returns HTTP 404)."""
34
+
35
+
36
+ class CanvasConflict(RuntimeError):
37
+ """The canvas was saved by someone else since the revision the writer started from (route returns HTTP 409).
38
+
39
+ `info` is what revision_info() says now, so the page can offer Reload or Keep mine."""
40
+
41
+ def __init__(self, name: str, expected: int, info: dict):
42
+ super().__init__(f"canvas {name} was saved by {info['by'] or 'someone else'} since revision {expected} (now revision {info['revision']})")
43
+ self.info = info
44
+
45
+
46
+ class Point(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
48
+ x: StrictFloat
49
+ y: StrictFloat
50
+
51
+
52
+ class Size(BaseModel):
53
+ model_config = ConfigDict(extra="forbid")
54
+ w: Annotated[StrictFloat, Field(gt=0)]
55
+ h: Annotated[StrictFloat, Field(gt=0)]
56
+
57
+
58
+ class Viewport(BaseModel):
59
+ model_config = ConfigDict(extra="forbid")
60
+ x: StrictFloat
61
+ y: StrictFloat
62
+ zoom: Annotated[StrictFloat, Field(gt=0)]
63
+
64
+
65
+ class Chart(BaseModel):
66
+ vega_lite: dict[str, Any]
67
+
68
+
69
+ WindowType = Literal["query", "chart", "note", "table", "shape", "text", "sticky", "frame", "placeholder"]
70
+ REQUIRED_BY_TYPE = {"query": "sql", "chart": "chart", "note": "text", "table": "table",
71
+ "shape": "shape", "placeholder": "placeholder"}
72
+ SKETCH_TYPES = ("shape", "text", "sticky", "frame", "placeholder") # version 2 only; titled by `label`, not `title`
73
+ ShapeKind = Literal["rect", "rounded", "ellipse", "diamond", "cylinder"]
74
+ LineKind = Literal["straight", "step", "curve"]
75
+
76
+
77
+ class PlaceholderColumn(BaseModel):
78
+ model_config = ConfigDict(extra="forbid")
79
+ name: str
80
+ type: str = ""
81
+ key: Optional[Literal["pk", "fk"]] = None
82
+
83
+
84
+ class Placeholder(BaseModel):
85
+ model_config = ConfigDict(extra="forbid")
86
+ id: Annotated[str, Field(pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")]
87
+ name: str
88
+ columns: list[PlaceholderColumn] = Field(default_factory=list)
89
+ linked: Optional[Annotated[str, Field(min_length=1)]] = None
90
+
91
+
92
+ class Window(BaseModel):
93
+ model_config = ConfigDict(extra="allow")
94
+ id: Annotated[str, Field(min_length=1)]
95
+ type: WindowType
96
+ title: Optional[Annotated[str, Field(min_length=1, pattern=r"^[^{}]+$")]] = None
97
+ position: Point
98
+ size: Size
99
+ sql: Optional[str] = None
100
+ chart: Optional[Chart] = None
101
+ text: Optional[str] = None
102
+ table: Optional[Annotated[str, Field(min_length=1)]] = None
103
+ collapsed: StrictBool = False
104
+ label: Optional[str] = None
105
+ shape: Optional[ShapeKind] = None
106
+ fill: Optional[str] = None
107
+ stroke: Optional[str] = None
108
+ font_size: Optional[Annotated[StrictFloat, Field(gt=0)]] = None
109
+ locked: Optional[StrictBool] = None
110
+ z: Optional[StrictFloat] = None
111
+ placeholder: Optional[Placeholder] = None
112
+
113
+ @model_validator(mode="after")
114
+ def _type_needs_its_field(self):
115
+ if self.type not in SKETCH_TYPES and self.title is None:
116
+ raise PydanticCustomError("type_requires_field", 'type "{type}" requires "{field}"',
117
+ {"type": self.type, "field": "title"})
118
+ field = REQUIRED_BY_TYPE.get(self.type)
119
+ if field is not None and getattr(self, field) is None:
120
+ raise PydanticCustomError("type_requires_field", 'type "{type}" requires "{field}"',
121
+ {"type": self.type, "field": field})
122
+ return self
123
+
124
+
125
+ class Link(BaseModel):
126
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
127
+ from_: Annotated[str, Field(alias="from", min_length=1)]
128
+ to: Annotated[str, Field(min_length=1)]
129
+
130
+
131
+ class RelationshipRef(BaseModel):
132
+ """What a connector remembers about the `sketch` edge saved from it (all optional)."""
133
+ model_config = ConfigDict(extra="forbid")
134
+ id: Optional[Annotated[str, Field(min_length=1)]] = None
135
+ source_column: Optional[Annotated[str, Field(min_length=1)]] = None
136
+ target_column: Optional[Annotated[str, Field(min_length=1)]] = None
137
+
138
+
139
+ class CanvasEdge(BaseModel):
140
+ """A user-drawn connector between two nodes. Version 2 only."""
141
+ model_config = ConfigDict(extra="forbid")
142
+ id: Annotated[str, Field(min_length=1)]
143
+ source: Annotated[str, Field(min_length=1)]
144
+ target: Annotated[str, Field(min_length=1)]
145
+ source_handle: Optional[Literal["t", "r", "b", "l"]] = None
146
+ target_handle: Optional[Literal["t", "r", "b", "l"]] = None
147
+ line: LineKind = "step"
148
+ arrow_start: StrictBool = False
149
+ arrow_end: StrictBool = True
150
+ dashed: StrictBool = False
151
+ label: Optional[str] = None
152
+ label_offset: StrictFloat = 0.0
153
+ color: Optional[str] = None
154
+ relationship: Optional[RelationshipRef] = None
155
+
156
+
157
+ class Canvas(BaseModel):
158
+ model_config = ConfigDict(extra="allow")
159
+ version: Annotated[StrictInt, Field(ge=1, le=2)]
160
+ name: Annotated[str, Field(min_length=1, pattern=NAME_PATTERN)]
161
+ profile: Optional[Annotated[str, Field(min_length=1)]] = None
162
+ warehouse_id: Optional[str] = None
163
+ viewport: Optional[Viewport] = None
164
+ windows: list[Window]
165
+ links: list[Link] = Field(default_factory=list)
166
+ edges: list[CanvasEdge] = Field(default_factory=list)
167
+
168
+
169
+ def _path(loc):
170
+ out = ""
171
+ for part in loc:
172
+ out += f"[{part}]" if isinstance(part, int) else (f".{part}" if out else str(part))
173
+ return out or "(root)"
174
+
175
+
176
+ def check_references(canvas: Canvas) -> None:
177
+ """Checks the schema cannot express: unique titles and ids, and links and connectors that name existing ids."""
178
+ if canvas.version == 1:
179
+ if canvas.profile is None:
180
+ raise CanvasInvalid('profile: required in version 1')
181
+ if "edges" in canvas.model_fields_set:
182
+ raise CanvasInvalid('edges: needs version 2')
183
+ ids, titles = {}, {}
184
+ for i, w in enumerate(canvas.windows):
185
+ if canvas.version == 1 and w.type in SKETCH_TYPES:
186
+ raise CanvasInvalid(f'windows[{i}].type: "{w.type}" needs version 2')
187
+ if w.id in ids:
188
+ raise CanvasInvalid(f'windows[{i}].id: duplicate id "{w.id}"')
189
+ ids[w.id] = i
190
+ if w.type in SKETCH_TYPES:
191
+ continue # sketch nodes are not referenced from SQL, so their labels need not be unique
192
+ if w.title in titles:
193
+ raise CanvasInvalid(f'windows[{i}].title: duplicate title "{w.title}"')
194
+ titles[w.title] = i
195
+ for i, link in enumerate(canvas.links):
196
+ for key, value in (("from", link.from_), ("to", link.to)):
197
+ if value not in ids:
198
+ raise CanvasInvalid(f'links[{i}].{key}: no window with id "{value}"')
199
+ edge_ids = set()
200
+ for i, edge in enumerate(canvas.edges):
201
+ if edge.id in edge_ids:
202
+ raise CanvasInvalid(f'edges[{i}].id: duplicate id "{edge.id}"')
203
+ edge_ids.add(edge.id)
204
+ for key, value in (("source", edge.source), ("target", edge.target)):
205
+ if value not in ids:
206
+ raise CanvasInvalid(f'edges[{i}].{key}: no window with id "{value}"')
207
+
208
+
209
+ def parse_canvas(data) -> Canvas:
210
+ """Validate a canvas body. Raises CanvasInvalid with a path-prefixed message."""
211
+ if not isinstance(data, dict):
212
+ raise CanvasInvalid("(root): body must be a JSON object")
213
+ try:
214
+ canvas = Canvas.model_validate(data)
215
+ except ValidationError as e:
216
+ first = e.errors()[0]
217
+ raise CanvasInvalid(f"{_path(first['loc'])}: {first['msg']}") from e
218
+ check_references(canvas)
219
+ return canvas
220
+
221
+
222
+ def path_for(name: str):
223
+ return CANVASES / f"{dbx.safe(name)}.json"
224
+
225
+
226
+ def list_canvases() -> list[str]:
227
+ return sorted(p.stem for p in CANVASES.glob("*.json"))
228
+
229
+
230
+ def history_dir(name: str):
231
+ """data/canvas_history/<name>/: the revision counter and the snapshots of one canvas."""
232
+ return CANVASES.parent / "canvas_history" / dbx.safe(name)
233
+
234
+
235
+ def _meta(name: str) -> dict:
236
+ try:
237
+ meta = json.loads((history_dir(name) / "meta.json").read_text(encoding="utf-8"))
238
+ except (OSError, ValueError):
239
+ return {}
240
+ return meta if isinstance(meta, dict) else {}
241
+
242
+
243
+ def _snapshots(name: str) -> list:
244
+ d = history_dir(name)
245
+ return sorted(d.glob("[0-9]*.json")) if d.is_dir() else []
246
+
247
+
248
+ def _atomic_write(path, text: str) -> None:
249
+ path.parent.mkdir(parents=True, exist_ok=True)
250
+ tmp = path.parent / (path.name + ".tmp")
251
+ tmp.write_text(text, encoding="utf-8")
252
+ os.replace(tmp, path) # overwrite; never leaves a half-written file behind
253
+
254
+
255
+ def revision_info(name: str) -> dict:
256
+ """{"name", "revision", "by", "snapshots"} for a saved canvas. `by` is ui, agent or undo (None before any save)."""
257
+ with LOCK:
258
+ if dbx.safe(name) != name or not path_for(name).is_file():
259
+ raise CanvasNotFound(f"canvas not found: {name}")
260
+ meta = _meta(name)
261
+ rev = meta.get("revision")
262
+ return {"name": name, "revision": rev if isinstance(rev, int) else 0, "by": meta.get("by"),
263
+ "snapshots": len(_snapshots(name))}
264
+
265
+
266
+ def write_canvas(data, by: str = "ui", snapshot: bool = False, expect: Optional[int] = None) -> tuple[str, int]:
267
+ """Validate and write data/canvases/<name>.json, storing the body exactly as received. Returns (name, revision).
268
+
269
+ Every write bumps the revision and records who made it (`by`). With `snapshot`, the file being replaced is kept
270
+ first (the newest KEEP_SNAPSHOTS are kept), which is what undo_canvas restores. With `expect`, the write is refused
271
+ (CanvasConflict) when the canvas exists and is not at that revision, so a page cannot overwrite an agent's newer save unseen.
272
+ """
273
+ name = parse_canvas(data).name
274
+ # On Windows (and macOS by default) "Hours" and "hours" are the same file. Refuse
275
+ # rather than silently overwrite a different canvas.
276
+ clash = [s for s in list_canvases() if s.lower() == name.lower() and s != name]
277
+ if clash:
278
+ raise CanvasInvalid(f"name: '{name}' differs from existing canvas '{clash[0]}' only by case; pick another name")
279
+ try:
280
+ text = json.dumps(data, indent=2, allow_nan=False)
281
+ except ValueError as e: # NaN or Infinity cannot be stored as JSON
282
+ raise CanvasInvalid(f"(root): {e}") from e
283
+ path = path_for(name)
284
+ with LOCK:
285
+ meta = _meta(name)
286
+ old = meta.get("revision") if isinstance(meta.get("revision"), int) else 0
287
+ if expect is not None and path.is_file() and old != expect:
288
+ raise CanvasConflict(name, expect, revision_info(name))
289
+ if snapshot and path.is_file():
290
+ _atomic_write(history_dir(name) / f"{old:08d}.json", path.read_text(encoding="utf-8"))
291
+ for stale in _snapshots(name)[:-KEEP_SNAPSHOTS]:
292
+ stale.unlink(missing_ok=True)
293
+ _atomic_write(path, text)
294
+ _atomic_write(history_dir(name) / "meta.json", json.dumps({"revision": old + 1, "by": by}))
295
+ return name, old + 1
296
+
297
+
298
+ def save_canvas(data) -> str:
299
+ """Validate and write data/canvases/<name>.json, storing the body exactly as received."""
300
+ return write_canvas(data)[0]
301
+
302
+
303
+ def undo_canvas(name: str) -> dict:
304
+ """Put back the newest snapshot of a canvas (the state before an agent's last write) and drop that snapshot.
305
+
306
+ Returns {"name", "revision", "snapshots"} (snapshots left). CanvasInvalid when there is nothing to undo.
307
+ """
308
+ with LOCK:
309
+ revision_info(name) # CanvasNotFound for an unknown canvas
310
+ snaps = _snapshots(name)
311
+ if not snaps:
312
+ raise CanvasInvalid(f"nothing to undo for canvas {name}")
313
+ data = json.loads(snaps[-1].read_text(encoding="utf-8"))
314
+ _, rev = write_canvas(data, by="undo")
315
+ snaps[-1].unlink(missing_ok=True)
316
+ return {"name": name, "revision": rev, "snapshots": len(_snapshots(name))}
317
+
318
+
319
+ def load_canvas(name: str) -> dict:
320
+ """Return the stored canvas object as saved. Validates it on the way out."""
321
+ if dbx.safe(name) != name: # names are validated on save, so this cannot exist
322
+ raise CanvasNotFound(f"canvas not found: {name}")
323
+ path = path_for(name)
324
+ if not path.is_file():
325
+ raise CanvasNotFound(f"canvas not found: {name}")
326
+ try:
327
+ data = json.loads(path.read_text(encoding="utf-8"))
328
+ except (OSError, ValueError) as e:
329
+ raise CanvasInvalid(f"(root): stored file is not valid JSON: {e}") from e
330
+ canvas = parse_canvas(data)
331
+ if canvas.name != name:
332
+ raise CanvasInvalid(f'name: "{canvas.name}" does not match file name "{name}"')
333
+ return data
@@ -0,0 +1,80 @@
1
+ """The `databricks-explore` command: parse options, pick the data folder, start the server and open the browser."""
2
+ import argparse
3
+ import os
4
+ import sys
5
+ import threading
6
+ import webbrowser
7
+ from pathlib import Path
8
+ from typing import Optional, Sequence
9
+
10
+ from . import __version__
11
+
12
+ HOST = "127.0.0.1" # the app never listens on anything else
13
+ DEFAULT_PORT, DEMO_PORT = 8765, 8767 # demo mode has its own port so the browser keeps its saved profile apart
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ """The option parser. Defaults for --port and --data-dir are filled in by main() because they depend on --demo."""
18
+ p = argparse.ArgumentParser(
19
+ prog="databricks-explore",
20
+ description="Local Unity Catalog explorer: query canvas, relationship graph and sketching. Serves on 127.0.0.1 only.",
21
+ epilog="Environment: EXPLORER_DATA_DIR (same as --data-dir), PORT (same as --port), EXPLORER_DEMO=1 (same as --demo).",
22
+ )
23
+ p.add_argument("--version", action="version", version=f"databricks-explore {__version__}")
24
+ p.add_argument("--demo", action="store_true", help="serve an invented workspace (no Databricks needed) on its own port and data folder")
25
+ p.add_argument("--redacted", action="store_true", help="with --demo: make query history text read <REDACTED>, to try that path")
26
+ p.add_argument("--port", type=int, help=f"port to listen on (default {DEFAULT_PORT}, or {DEMO_PORT} with --demo)")
27
+ p.add_argument("--data-dir", metavar="DIR", help="where the app keeps its cache, canvases and relationships "
28
+ "(default: your per-user data folder, with a demo/ folder inside it for --demo)")
29
+ p.add_argument("--debug", action="store_true", help="Werkzeug debugger and reloader (the debugger can run code from an error page)")
30
+ p.add_argument("--no-browser", action="store_true", help="do not open a browser tab")
31
+ return p
32
+
33
+
34
+ def source_checkout_hint(data_dir_given: bool) -> Optional[str]:
35
+ """One line of advice when running from a source checkout that still has its old data/ folder, else None."""
36
+ if data_dir_given:
37
+ return None
38
+ root = Path(__file__).resolve().parents[2]
39
+ if (root / "pyproject.toml").is_file() and (root / "data").is_dir():
40
+ return f"Hint: {root / 'data'} is from an earlier version and is not used now. To keep using it: --data-dir {root / 'data'}"
41
+ return None
42
+
43
+
44
+ def main(argv: Optional[Sequence[str]] = None) -> int:
45
+ """Run the app. Returns the exit status."""
46
+ args = build_parser().parse_args(argv)
47
+ # dbx reads these when it is first imported, so they are set before the app is.
48
+ demo = args.demo or os.environ.get("EXPLORER_DEMO") == "1"
49
+ if demo:
50
+ os.environ["EXPLORER_DEMO"] = "1"
51
+ if args.redacted:
52
+ if not demo:
53
+ print("--redacted only applies with --demo", file=sys.stderr)
54
+ return 2
55
+ os.environ["EXPLORER_DEMO_REDACTED"] = "1"
56
+ data_dir_given = bool(args.data_dir or os.environ.get("EXPLORER_DATA_DIR"))
57
+ if args.data_dir:
58
+ os.environ["EXPLORER_DATA_DIR"] = str(Path(args.data_dir).expanduser().resolve())
59
+ port = args.port if args.port is not None else int(os.environ.get("PORT") or (DEMO_PORT if demo else DEFAULT_PORT))
60
+ debug = args.debug or os.environ.get("EXPLORER_DEBUG") == "1"
61
+
62
+ from . import dbx
63
+ from .app import app
64
+
65
+ url = f"http://{HOST}:{port}"
66
+ # With the reloader the first process only supervises; the server (and the message) belong to its child.
67
+ serving = not debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true"
68
+ if serving:
69
+ print(f"Databricks Explorer {__version__}{' (demo mode)' if demo else ''} at {url}", flush=True)
70
+ print(f"Data folder: {dbx.DATA}", flush=True)
71
+ if hint := source_checkout_hint(data_dir_given):
72
+ print(hint, flush=True)
73
+ if not args.no_browser:
74
+ threading.Timer(1.0, webbrowser.open, args=(url,)).start()
75
+ try:
76
+ app.run(host=HOST, port=port, debug=debug)
77
+ except OSError as e:
78
+ print(f"Could not listen on {HOST}:{port} ({e.strerror or e}). Pick another port with --port.", file=sys.stderr)
79
+ return 1
80
+ return 0