databricks-tellr 0.1.0__tar.gz

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,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: databricks-tellr
3
+ Version: 0.1.0
4
+ Summary: Tellr deployment tooling for Databricks Apps
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: databricks-sdk>=0.20.0
8
+ Requires-Dist: psycopg2-binary>=2.9.0
9
+ Requires-Dist: pyyaml>=6.0.0
10
+
11
+ # databricks-tellr
12
+
13
+ Python deployment tooling for Tellr on Databricks Apps.
@@ -0,0 +1,3 @@
1
+ # databricks-tellr
2
+
3
+ Python deployment tooling for Tellr on Databricks Apps.
@@ -0,0 +1,5 @@
1
+ """Tellr deployment package for Databricks Apps."""
2
+
3
+ from databricks_tellr.deploy import delete, setup, update
4
+
5
+ __all__ = ["setup", "update", "delete"]
@@ -0,0 +1,22 @@
1
+ # Databricks App Configuration
2
+ name: "tellr"
3
+ description: "Tellr - AI Slide Generator"
4
+
5
+ command:
6
+ - "sh"
7
+ - "-c"
8
+ - |
9
+ pip install -r requirements.txt && \
10
+ python -m databricks_tellr_app.run
11
+
12
+ env:
13
+ - name: ENVIRONMENT
14
+ value: "production"
15
+ - name: LAKEBASE_INSTANCE
16
+ value: "${LAKEBASE_INSTANCE}"
17
+ - name: LAKEBASE_SCHEMA
18
+ value: "${LAKEBASE_SCHEMA}"
19
+ - name: DATABRICKS_HOST
20
+ valueFrom: "system.databricks_host"
21
+ - name: DATABRICKS_TOKEN
22
+ valueFrom: "system.databricks_token"
@@ -0,0 +1,2 @@
1
+ # Generated by databricks-tellr setup
2
+ databricks-tellr-app${APP_VERSION_SUFFIX}
@@ -0,0 +1,766 @@
1
+ """Databricks Apps deployment utilities for Tellr."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import uuid
8
+ from importlib import metadata
9
+ from pathlib import Path
10
+ from string import Template
11
+ from typing import Any
12
+
13
+ import yaml
14
+ from databricks.sdk import WorkspaceClient
15
+ from databricks.sdk.service.apps import (
16
+ App,
17
+ AppDeployment,
18
+ AppResource,
19
+ AppResourceDatabase,
20
+ AppResourceDatabaseDatabasePermission,
21
+ ComputeSize,
22
+ )
23
+ from databricks.sdk.service.database import DatabaseInstance
24
+ from databricks.sdk.service.workspace import ImportFormat
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class DeploymentError(Exception):
30
+ """Raised when deployment fails."""
31
+
32
+
33
+ def setup(
34
+ lakebase_name: str | None = None,
35
+ schema_name: str | None = None,
36
+ app_name: str | None = None,
37
+ app_file_workspace_path: str | None = None,
38
+ lakebase_compute: str | None = "CU_1",
39
+ app_compute: str | None = "MEDIUM",
40
+ app_version: str | None = None,
41
+ client: WorkspaceClient | None = None,
42
+ databricks_cfg_profile_name: str | None = None,
43
+ config_yaml_path: str | None = None,
44
+ ) -> dict[str, Any]:
45
+ """
46
+ Deploy Tellr to Databricks Apps using PyPI-only installs.
47
+
48
+ Steps:
49
+ 1. Create/get Lakebase instance
50
+ 2. Generate requirements.txt with pinned app version
51
+ 3. Generate app.yaml with env vars
52
+ 4. Upload files to workspace
53
+ 5. Create Databricks App with database resource
54
+ 6. Return app URL
55
+
56
+ Authentication flow;
57
+ - If a Workspace Client is provided, use it
58
+ - If databricks_cfg_profile_name is provided, use it to create a Workspace Client
59
+ - Else, use the default Databricks CLI profile to create a Workspace Client
60
+ """
61
+
62
+ # create workspace client if necessary
63
+ if client:
64
+ pass
65
+ elif databricks_cfg_profile_name:
66
+ client = WorkspaceClient(profile=databricks_cfg_profile_name)
67
+ else:
68
+ client = WorkspaceClient()
69
+
70
+ # check if config_yaml_path is provided and error if yaml and other args are set
71
+ if config_yaml_path and (lakebase_name or schema_name or app_name or app_file_workspace_path):
72
+ raise ValueError("config_yaml_path cannot be used with other arguments")
73
+
74
+ # load config from yaml if provided
75
+ config = None
76
+ if config_yaml_path:
77
+ config = _load_deployment_config(config_yaml_path)
78
+
79
+ # set args from config if provided
80
+ if config:
81
+ lakebase_name = config.get("lakebase_name", lakebase_name)
82
+ schema_name = config.get("schema_name", schema_name)
83
+ app_name = config.get("app_name", app_name)
84
+ app_file_workspace_path = config.get("app_file_workspace_path", app_file_workspace_path)
85
+ lakebase_compute = config.get("lakebase_compute", lakebase_compute)
86
+ app_compute = config.get("app_compute", app_compute)
87
+
88
+ if not all([lakebase_name, schema_name, app_name, app_file_workspace_path]):
89
+ raise ValueError("lakebase_name, schema_name, app_name, and app_file_workspace_path are required")
90
+
91
+ _ensure_lakebase_instance(client, lakebase_name, lakebase_compute or "CU_1")
92
+ requirements_content = _render_requirements(app_version)
93
+ app_yaml_content = _render_app_yaml(lakebase_name, schema_name)
94
+
95
+ _upload_artifacts(
96
+ client,
97
+ app_file_workspace_path,
98
+ {
99
+ "requirements.txt": requirements_content,
100
+ "app.yaml": app_yaml_content,
101
+ },
102
+ )
103
+
104
+ app = _create_app(
105
+ client,
106
+ app_name,
107
+ app_file_workspace_path,
108
+ app_compute,
109
+ lakebase_name,
110
+ )
111
+ _setup_schema(client, lakebase_name, schema_name, _get_app_client_id(app))
112
+
113
+ return {"app_name": app.name, "url": getattr(app, "url", None)}
114
+
115
+
116
+ def _load_deployment_config(config_yaml_path: str) -> dict[str, str]:
117
+ """
118
+ Load deployment settings from config/deployment.yaml-style files.
119
+
120
+ Expected structure (see config/deployment.yaml):
121
+ environments:
122
+ development:
123
+ app_name: ...
124
+ workspace_path: ...
125
+ compute_size: ...
126
+ lakebase:
127
+ database_name: ...
128
+ schema: ...
129
+ capacity: ...
130
+ """
131
+ with open(config_yaml_path, "r", encoding="utf-8") as handle:
132
+ config = yaml.safe_load(handle) or {}
133
+
134
+ environments = config.get("environments", {})
135
+ if not environments:
136
+ raise ValueError("No environments found in deployment config")
137
+
138
+ env_name = os.getenv("ENVIRONMENT", "development")
139
+ if env_name not in environments:
140
+ raise ValueError(f"Environment '{env_name}' not found in deployment config")
141
+
142
+ env_config = environments[env_name]
143
+ lakebase_config = env_config.get("lakebase", {})
144
+
145
+ return {
146
+ "app_name": env_config.get("app_name"),
147
+ "app_file_workspace_path": env_config.get("workspace_path"),
148
+ "app_compute": env_config.get("compute_size"),
149
+ "lakebase_name": lakebase_config.get("database_name"),
150
+ "schema_name": lakebase_config.get("schema"),
151
+ "lakebase_compute": lakebase_config.get("capacity"),
152
+ }
153
+
154
+
155
+ def update(app_name: str, app_file_workspace_path: str, app_version: str | None = None) -> None:
156
+ """Update the app deployment with a new app version."""
157
+ client = WorkspaceClient()
158
+ requirements_content = _render_requirements(app_version)
159
+
160
+ _upload_artifacts(
161
+ client,
162
+ app_file_workspace_path,
163
+ {
164
+ "requirements.txt": requirements_content,
165
+ },
166
+ )
167
+
168
+ deployment = AppDeployment(source_code_path=app_file_workspace_path)
169
+ client.apps.deploy_and_wait(app_name=app_name, app_deployment=deployment)
170
+
171
+
172
+ def delete(app_name: str) -> None:
173
+ """Delete the Databricks App."""
174
+ client = WorkspaceClient()
175
+ client.apps.delete(name=app_name)
176
+
177
+
178
+ def _render_app_yaml(lakebase_name: str, schema_name: str) -> str:
179
+ template_path = Path(__file__).parent / "_templates" / "app.yaml.template"
180
+ template = Template(template_path.read_text())
181
+ return template.substitute(
182
+ LAKEBASE_INSTANCE=lakebase_name,
183
+ LAKEBASE_SCHEMA=schema_name,
184
+ )
185
+
186
+
187
+ def _render_requirements(app_version: str | None) -> str:
188
+ resolved_version = app_version or _resolve_installed_app_version()
189
+ version_suffix = f"=={resolved_version}" if resolved_version else ""
190
+ template_path = Path(__file__).parent / "_templates" / "requirements.txt.template"
191
+ template = Template(template_path.read_text())
192
+ return template.substitute(APP_VERSION_SUFFIX=version_suffix)
193
+
194
+
195
+ def _resolve_installed_app_version() -> str | None:
196
+ try:
197
+ return metadata.version("databricks-tellr-app")
198
+ except metadata.PackageNotFoundError:
199
+ return None
200
+
201
+
202
+ def _upload_artifacts(
203
+ client: WorkspaceClient,
204
+ workspace_path: str,
205
+ files: dict[str, str],
206
+ ) -> None:
207
+ client.workspace.mkdirs(workspace_path)
208
+ for name, content in files.items():
209
+ upload_path = f"{workspace_path}/{name}"
210
+ client.workspace.upload(
211
+ upload_path,
212
+ content.encode("utf-8"),
213
+ format=ImportFormat.AUTO,
214
+ overwrite=True,
215
+ )
216
+ logger.info("Uploaded %s to %s", name, upload_path)
217
+
218
+
219
+ def _ensure_lakebase_instance(
220
+ client: WorkspaceClient, instance_name: str, capacity: str
221
+ ) -> None:
222
+ try:
223
+ client.database.get_database_instance(name=instance_name)
224
+ return
225
+ except Exception:
226
+ pass
227
+
228
+ client.database.create_database_instance_and_wait(
229
+ DatabaseInstance(name=instance_name, capacity=capacity)
230
+ )
231
+
232
+
233
+ def _create_app(
234
+ client: WorkspaceClient,
235
+ app_name: str,
236
+ workspace_path: str,
237
+ compute_size: str,
238
+ lakebase_instance: str,
239
+ ) -> App:
240
+ resources = [
241
+ AppResource(
242
+ name="app_database",
243
+ database=AppResourceDatabase(
244
+ instance_name=lakebase_instance,
245
+ database_name="databricks_postgres",
246
+ permission=AppResourceDatabaseDatabasePermission.CAN_CONNECT_AND_CREATE,
247
+ ),
248
+ )
249
+ ]
250
+
251
+ app = App(
252
+ name=app_name,
253
+ compute_size=ComputeSize(compute_size),
254
+ default_source_code_path=workspace_path,
255
+ resources=resources,
256
+ )
257
+ result = client.apps.create_and_wait(app)
258
+
259
+ deployment = AppDeployment(source_code_path=workspace_path)
260
+ client.apps.deploy_and_wait(app_name=app_name, app_deployment=deployment)
261
+ return client.apps.get(name=app_name)
262
+
263
+
264
+ def _get_app_client_id(app: App) -> str:
265
+ if hasattr(app, "service_principal_client_id") and app.service_principal_client_id:
266
+ return app.service_principal_client_id
267
+ if hasattr(app, "service_principal_id") and app.service_principal_id:
268
+ return str(app.service_principal_id)
269
+ raise DeploymentError("Could not determine app service principal client ID")
270
+
271
+
272
+ def _setup_schema(
273
+ client: WorkspaceClient, instance_name: str, schema: str, client_id: str
274
+ ) -> None:
275
+ try:
276
+ import psycopg2
277
+ except ImportError as exc:
278
+ raise DeploymentError("psycopg2-binary is required for schema setup") from exc
279
+
280
+ instance = client.database.get_database_instance(name=instance_name)
281
+ user = client.current_user.me().user_name
282
+ credential = client.database.generate_database_credential(
283
+ request_id=str(uuid.uuid4()),
284
+ instance_names=[instance_name],
285
+ )
286
+
287
+ conn = psycopg2.connect(
288
+ host=instance.read_write_dns,
289
+ port=5432,
290
+ user=user,
291
+ password=credential.token,
292
+ dbname="databricks_postgres",
293
+ sslmode="require",
294
+ )
295
+ conn.autocommit = True
296
+
297
+ with conn.cursor() as cur:
298
+ cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema}"')
299
+ cur.execute(f'GRANT USAGE ON SCHEMA "{schema}" TO "{client_id}"')
300
+ cur.execute(f'GRANT CREATE ON SCHEMA "{schema}" TO "{client_id}"')
301
+ cur.execute(
302
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "{schema}" TO "{client_id}"'
303
+ )
304
+ cur.execute(
305
+ f'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "{schema}" TO "{client_id}"'
306
+ )
307
+ cur.execute(
308
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{schema}" '
309
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "{client_id}"'
310
+ )
311
+ cur.execute(
312
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{schema}" '
313
+ f'GRANT USAGE, SELECT ON SEQUENCES TO "{client_id}"'
314
+ )
315
+
316
+ conn.close()
317
+ """Deployment orchestration for Tellr on Databricks Apps.
318
+
319
+ This module provides the main setup/update/delete functions for deploying
320
+ the Tellr AI slide generator to Databricks Apps from a notebook.
321
+ """
322
+
323
+ import logging
324
+ import os
325
+ import tempfile
326
+ import uuid
327
+ from importlib import resources
328
+ from pathlib import Path
329
+ from string import Template
330
+ from typing import Optional
331
+
332
+ import psycopg2
333
+ from databricks.sdk import WorkspaceClient
334
+ from databricks.sdk.service.apps import (
335
+ App,
336
+ AppDeployment,
337
+ AppResource,
338
+ AppResourceDatabase,
339
+ AppResourceDatabaseDatabasePermission,
340
+ ComputeSize,
341
+ )
342
+ from databricks.sdk.service.database import DatabaseInstance
343
+ from databricks.sdk.service.workspace import ImportFormat
344
+
345
+ logger = logging.getLogger(__name__)
346
+
347
+
348
+ class DeploymentError(Exception):
349
+ """Raised when deployment fails."""
350
+
351
+ pass
352
+
353
+
354
+ def setup(
355
+ lakebase_name: str,
356
+ schema_name: str,
357
+ app_name: str,
358
+ app_file_workspace_path: str,
359
+ lakebase_compute: str = "CU_1",
360
+ app_compute: str = "MEDIUM",
361
+ app_version: Optional[str] = None,
362
+ description: str = "Tellr AI Slide Generator",
363
+ ) -> dict:
364
+ """Deploy Tellr to Databricks Apps.
365
+
366
+ This function creates all necessary infrastructure and deploys the app:
367
+ 1. Creates/gets Lakebase database instance
368
+ 2. Generates requirements.txt with pinned app version
369
+ 3. Generates app.yaml with environment variables
370
+ 4. Uploads files to workspace
371
+ 5. Creates Databricks App with database resource
372
+ 6. Sets up database schema and seeds default data
373
+
374
+ Args:
375
+ lakebase_name: Name for the Lakebase database instance
376
+ schema_name: PostgreSQL schema name for app tables
377
+ app_name: Name for the Databricks App
378
+ app_file_workspace_path: Workspace path to upload app files
379
+ lakebase_compute: Lakebase capacity (CU_1, CU_2, CU_4, CU_8)
380
+ app_compute: App compute size (MEDIUM, LARGE, LIQUID)
381
+ app_version: Specific databricks-tellr-app version (default: latest)
382
+ description: App description
383
+
384
+ Returns:
385
+ Dictionary with deployment info:
386
+ - url: App URL
387
+ - app_name: Created app name
388
+ - lakebase_name: Database instance name
389
+ - schema_name: Schema name
390
+ - status: "created"
391
+
392
+ Raises:
393
+ DeploymentError: If deployment fails
394
+ """
395
+ print(f"🚀 Deploying Tellr to Databricks Apps...")
396
+ print(f" App name: {app_name}")
397
+ print(f" Workspace path: {app_file_workspace_path}")
398
+ print(f" Lakebase: {lakebase_name} (capacity: {lakebase_compute})")
399
+ print(f" Schema: {schema_name}")
400
+ print()
401
+
402
+ # Get workspace client (uses notebook auth)
403
+ ws = WorkspaceClient()
404
+
405
+ try:
406
+ # Step 1: Create/get Lakebase instance
407
+ print("📊 Setting up Lakebase database...")
408
+ lakebase_result = _get_or_create_lakebase(ws, lakebase_name, lakebase_compute)
409
+ print(f" ✅ Lakebase: {lakebase_result['name']} ({lakebase_result['status']})")
410
+ print()
411
+
412
+ # Step 2: Generate and upload files
413
+ print("📁 Preparing deployment files...")
414
+ with tempfile.TemporaryDirectory() as staging_dir:
415
+ staging = Path(staging_dir)
416
+
417
+ # Generate requirements.txt
418
+ _write_requirements(staging, app_version)
419
+ print(" ✓ Generated requirements.txt")
420
+
421
+ # Generate app.yaml
422
+ _write_app_yaml(staging, lakebase_name, schema_name)
423
+ print(" ✓ Generated app.yaml")
424
+
425
+ # Upload to workspace
426
+ print(f"☁️ Uploading to: {app_file_workspace_path}")
427
+ _upload_files(ws, staging, app_file_workspace_path)
428
+ print(" ✅ Files uploaded")
429
+ print()
430
+
431
+ # Step 3: Create app
432
+ print(f"🔧 Creating Databricks App: {app_name}")
433
+ app = _create_app(
434
+ ws,
435
+ app_name=app_name,
436
+ description=description,
437
+ workspace_path=app_file_workspace_path,
438
+ compute_size=app_compute,
439
+ lakebase_name=lakebase_name,
440
+ )
441
+ print(f" ✅ App created")
442
+ if app.url:
443
+ print(f" 🌐 URL: {app.url}")
444
+ print()
445
+
446
+ # Step 4: Set up database schema
447
+ print("📊 Setting up database schema...")
448
+ _setup_database_schema(ws, app, lakebase_name, schema_name)
449
+ print(f" ✅ Schema '{schema_name}' configured")
450
+ print()
451
+
452
+ print("✅ Deployment complete!")
453
+ return {
454
+ "url": app.url,
455
+ "app_name": app_name,
456
+ "lakebase_name": lakebase_name,
457
+ "schema_name": schema_name,
458
+ "status": "created",
459
+ }
460
+
461
+ except Exception as e:
462
+ raise DeploymentError(f"Deployment failed: {e}") from e
463
+
464
+
465
+ def update(
466
+ app_name: str,
467
+ app_file_workspace_path: str,
468
+ lakebase_name: str,
469
+ schema_name: str,
470
+ app_version: Optional[str] = None,
471
+ ) -> dict:
472
+ """Deploy a new version of an existing Tellr app.
473
+
474
+ Updates the app files and triggers a new deployment.
475
+
476
+ Args:
477
+ app_name: Name of the existing Databricks App
478
+ app_file_workspace_path: Workspace path with app files
479
+ lakebase_name: Lakebase instance name
480
+ schema_name: Schema name
481
+ app_version: Specific databricks-tellr-app version (default: latest)
482
+
483
+ Returns:
484
+ Dictionary with deployment info
485
+
486
+ Raises:
487
+ DeploymentError: If update fails
488
+ """
489
+ print(f"🔄 Updating Tellr app: {app_name}")
490
+
491
+ ws = WorkspaceClient()
492
+
493
+ try:
494
+ # Generate and upload updated files
495
+ with tempfile.TemporaryDirectory() as staging_dir:
496
+ staging = Path(staging_dir)
497
+
498
+ _write_requirements(staging, app_version)
499
+ _write_app_yaml(staging, lakebase_name, schema_name)
500
+ _upload_files(ws, staging, app_file_workspace_path)
501
+ print(" ✅ Files updated")
502
+
503
+ # Trigger new deployment
504
+ print(" ⏳ Deploying...")
505
+ deployment = AppDeployment(source_code_path=app_file_workspace_path)
506
+ result = ws.apps.deploy_and_wait(app_name=app_name, app_deployment=deployment)
507
+ print(f" ✅ Deployment completed: {result.deployment_id}")
508
+
509
+ app = ws.apps.get(name=app_name)
510
+ if app.url:
511
+ print(f" 🌐 URL: {app.url}")
512
+
513
+ return {
514
+ "url": app.url,
515
+ "app_name": app_name,
516
+ "deployment_id": result.deployment_id,
517
+ "status": "updated",
518
+ }
519
+
520
+ except Exception as e:
521
+ raise DeploymentError(f"Update failed: {e}") from e
522
+
523
+
524
+ def delete(app_name: str) -> dict:
525
+ """Delete a Tellr app.
526
+
527
+ Note: This does not delete the Lakebase instance or data.
528
+
529
+ Args:
530
+ app_name: Name of the app to delete
531
+
532
+ Returns:
533
+ Dictionary with deletion status
534
+
535
+ Raises:
536
+ DeploymentError: If deletion fails
537
+ """
538
+ print(f"🗑️ Deleting app: {app_name}")
539
+
540
+ ws = WorkspaceClient()
541
+
542
+ try:
543
+ ws.apps.delete(name=app_name)
544
+ print(" ✅ App deleted")
545
+ return {"app_name": app_name, "status": "deleted"}
546
+ except Exception as e:
547
+ raise DeploymentError(f"Deletion failed: {e}") from e
548
+
549
+
550
+ # -----------------------------------------------------------------------------
551
+ # Internal functions
552
+ # -----------------------------------------------------------------------------
553
+
554
+
555
+ def _get_or_create_lakebase(
556
+ ws: WorkspaceClient, database_name: str, capacity: str
557
+ ) -> dict:
558
+ """Get or create a Lakebase database instance."""
559
+ try:
560
+ # Check if exists
561
+ existing = ws.database.get_database_instance(name=database_name)
562
+ return {
563
+ "name": existing.name,
564
+ "status": "exists",
565
+ "state": existing.state.value if existing.state else "UNKNOWN",
566
+ }
567
+ except Exception as e:
568
+ error_str = str(e).lower()
569
+ if "not found" not in error_str and "does not exist" not in error_str:
570
+ raise
571
+
572
+ # Create new instance
573
+ instance = ws.database.create_database_instance_and_wait(
574
+ DatabaseInstance(name=database_name, capacity=capacity)
575
+ )
576
+ return {
577
+ "name": instance.name,
578
+ "status": "created",
579
+ "state": instance.state.value if instance.state else "RUNNING",
580
+ }
581
+
582
+
583
+ def _write_requirements(staging_dir: Path, app_version: Optional[str]) -> None:
584
+ """Generate requirements.txt with app package."""
585
+ # Get version
586
+ if not app_version:
587
+ app_version = _get_latest_app_version()
588
+
589
+ # Load template
590
+ template_content = _load_template("requirements.txt.template")
591
+ content = Template(template_content).substitute(APP_VERSION=app_version)
592
+
593
+ (staging_dir / "requirements.txt").write_text(content)
594
+
595
+
596
+ def _write_app_yaml(staging_dir: Path, lakebase_name: str, schema_name: str) -> None:
597
+ """Generate app.yaml with environment variables."""
598
+ template_content = _load_template("app.yaml.template")
599
+ content = Template(template_content).substitute(
600
+ LAKEBASE_INSTANCE=lakebase_name,
601
+ LAKEBASE_SCHEMA=schema_name,
602
+ )
603
+ (staging_dir / "app.yaml").write_text(content)
604
+
605
+
606
+ def _load_template(template_name: str) -> str:
607
+ """Load a template file from package resources."""
608
+ try:
609
+ # Python 3.9+ style
610
+ files = resources.files("databricks_tellr") / "_templates" / template_name
611
+ return files.read_text()
612
+ except (TypeError, AttributeError):
613
+ # Fallback for older Python
614
+ with resources.open_text(
615
+ "databricks_tellr._templates", template_name
616
+ ) as f:
617
+ return f.read()
618
+
619
+
620
+ def _get_latest_app_version() -> str:
621
+ """Get the latest version of databricks-tellr-app from PyPI.
622
+
623
+ Falls back to a default version if PyPI is unreachable.
624
+ """
625
+ try:
626
+ import urllib.request
627
+ import json
628
+
629
+ url = "https://pypi.org/pypi/databricks-tellr-app/json"
630
+ with urllib.request.urlopen(url, timeout=5) as response:
631
+ data = json.loads(response.read())
632
+ return data["info"]["version"]
633
+ except Exception:
634
+ # Fallback to a reasonable default
635
+ return "0.1.0"
636
+
637
+
638
+ def _upload_files(
639
+ ws: WorkspaceClient, staging_dir: Path, workspace_path: str
640
+ ) -> None:
641
+ """Upload files from staging directory to workspace."""
642
+ # Ensure directory exists
643
+ try:
644
+ ws.workspace.mkdirs(workspace_path)
645
+ except Exception:
646
+ pass # May already exist
647
+
648
+ # Upload each file
649
+ for file_path in staging_dir.iterdir():
650
+ if file_path.is_file():
651
+ workspace_file_path = f"{workspace_path}/{file_path.name}"
652
+ with open(file_path, "rb") as f:
653
+ ws.workspace.upload(
654
+ workspace_file_path,
655
+ f,
656
+ format=ImportFormat.AUTO,
657
+ overwrite=True,
658
+ )
659
+
660
+
661
+ def _create_app(
662
+ ws: WorkspaceClient,
663
+ app_name: str,
664
+ description: str,
665
+ workspace_path: str,
666
+ compute_size: str,
667
+ lakebase_name: str,
668
+ ) -> App:
669
+ """Create Databricks App with database resource."""
670
+ compute_size_enum = ComputeSize(compute_size)
671
+
672
+ # Database resource
673
+ resources = [
674
+ AppResource(
675
+ name="app_database",
676
+ database=AppResourceDatabase(
677
+ instance_name=lakebase_name,
678
+ database_name="databricks_postgres",
679
+ permission=AppResourceDatabaseDatabasePermission.CAN_CONNECT_AND_CREATE,
680
+ ),
681
+ )
682
+ ]
683
+
684
+ # Create app
685
+ app = App(
686
+ name=app_name,
687
+ description=description,
688
+ compute_size=compute_size_enum,
689
+ default_source_code_path=workspace_path,
690
+ resources=resources,
691
+ user_api_scopes=[
692
+ "sql",
693
+ "dashboards.genie",
694
+ "catalog.tables:read",
695
+ "catalog.schemas:read",
696
+ "catalog.catalogs:read",
697
+ "serving.serving-endpoints",
698
+ ],
699
+ )
700
+
701
+ result = ws.apps.create_and_wait(app)
702
+
703
+ # Trigger initial deployment
704
+ deployment = AppDeployment(source_code_path=workspace_path)
705
+ ws.apps.deploy_and_wait(app_name=app_name, app_deployment=deployment)
706
+
707
+ # Refresh to get URL
708
+ return ws.apps.get(name=app_name)
709
+
710
+
711
+ def _setup_database_schema(
712
+ ws: WorkspaceClient, app: App, lakebase_name: str, schema_name: str
713
+ ) -> None:
714
+ """Set up database schema and grant permissions to app."""
715
+ # Get app's service principal client ID
716
+ client_id = None
717
+ if hasattr(app, "service_principal_client_id") and app.service_principal_client_id:
718
+ client_id = app.service_principal_client_id
719
+ elif hasattr(app, "service_principal_id") and app.service_principal_id:
720
+ client_id = str(app.service_principal_id)
721
+
722
+ if not client_id:
723
+ print(" ⚠️ Could not get app client ID - schema setup skipped")
724
+ return
725
+
726
+ # Get connection info
727
+ instance = ws.database.get_database_instance(name=lakebase_name)
728
+ user = ws.current_user.me().user_name
729
+
730
+ # Generate credential
731
+ cred = ws.database.generate_database_credential(
732
+ request_id=str(uuid.uuid4()),
733
+ instance_names=[lakebase_name],
734
+ )
735
+
736
+ # Connect and create schema
737
+ conn = psycopg2.connect(
738
+ host=instance.read_write_dns,
739
+ port=5432,
740
+ user=user,
741
+ password=cred.token,
742
+ dbname="databricks_postgres",
743
+ sslmode="require",
744
+ )
745
+ conn.autocommit = True
746
+
747
+ with conn.cursor() as cur:
748
+ cur.execute(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"')
749
+ cur.execute(f'GRANT USAGE ON SCHEMA "{schema_name}" TO "{client_id}"')
750
+ cur.execute(f'GRANT CREATE ON SCHEMA "{schema_name}" TO "{client_id}"')
751
+ cur.execute(
752
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA "{schema_name}" TO "{client_id}"'
753
+ )
754
+ cur.execute(
755
+ f'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "{schema_name}" TO "{client_id}"'
756
+ )
757
+ cur.execute(
758
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{schema_name}" '
759
+ f'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO "{client_id}"'
760
+ )
761
+ cur.execute(
762
+ f'ALTER DEFAULT PRIVILEGES IN SCHEMA "{schema_name}" '
763
+ f'GRANT USAGE, SELECT ON SEQUENCES TO "{client_id}"'
764
+ )
765
+
766
+ conn.close()
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: databricks-tellr
3
+ Version: 0.1.0
4
+ Summary: Tellr deployment tooling for Databricks Apps
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: databricks-sdk>=0.20.0
8
+ Requires-Dist: psycopg2-binary>=2.9.0
9
+ Requires-Dist: pyyaml>=6.0.0
10
+
11
+ # databricks-tellr
12
+
13
+ Python deployment tooling for Tellr on Databricks Apps.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ databricks_tellr/__init__.py
4
+ databricks_tellr/deploy.py
5
+ databricks_tellr.egg-info/PKG-INFO
6
+ databricks_tellr.egg-info/SOURCES.txt
7
+ databricks_tellr.egg-info/dependency_links.txt
8
+ databricks_tellr.egg-info/requires.txt
9
+ databricks_tellr.egg-info/top_level.txt
10
+ databricks_tellr/_templates/app.yaml.template
11
+ databricks_tellr/_templates/requirements.txt.template
@@ -0,0 +1,3 @@
1
+ databricks-sdk>=0.20.0
2
+ psycopg2-binary>=2.9.0
3
+ pyyaml>=6.0.0
@@ -0,0 +1 @@
1
+ databricks_tellr
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "databricks-tellr"
7
+ version = "0.1.0"
8
+ description = "Tellr deployment tooling for Databricks Apps"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "databricks-sdk>=0.20.0",
13
+ "psycopg2-binary>=2.9.0",
14
+ "pyyaml>=6.0.0",
15
+ ]
16
+
17
+ [tool.setuptools.packages.find]
18
+ where = ["."]
19
+ include = ["databricks_tellr*"]
20
+
21
+ [tool.setuptools.package-data]
22
+ databricks_tellr = ["_templates/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+