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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|