semblance-databricks 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.
Files changed (42) hide show
  1. semblance_databricks/__init__.py +19 -0
  2. semblance_databricks/app.py +104 -0
  3. semblance_databricks/auth.py +145 -0
  4. semblance_databricks/cli.py +112 -0
  5. semblance_databricks/compatibility.py +30 -0
  6. semblance_databricks/compatibility.yaml +269 -0
  7. semblance_databricks/config.py +52 -0
  8. semblance_databricks/errors.py +72 -0
  9. semblance_databricks/fixtures/__init__.py +5 -0
  10. semblance_databricks/fixtures/defaults/__init__.py +0 -0
  11. semblance_databricks/fixtures/defaults/acme.yaml +47 -0
  12. semblance_databricks/fixtures/loaders.py +196 -0
  13. semblance_databricks/http.py +34 -0
  14. semblance_databricks/ids.py +88 -0
  15. semblance_databricks/models/__init__.py +1 -0
  16. semblance_databricks/pagination.py +48 -0
  17. semblance_databricks/py.typed +0 -0
  18. semblance_databricks/registry.py +29 -0
  19. semblance_databricks/services/__init__.py +1 -0
  20. semblance_databricks/services/clusters/__init__.py +3 -0
  21. semblance_databricks/services/clusters/routes.py +191 -0
  22. semblance_databricks/services/dbfs/__init__.py +3 -0
  23. semblance_databricks/services/dbfs/routes.py +126 -0
  24. semblance_databricks/services/dbsql/__init__.py +3 -0
  25. semblance_databricks/services/dbsql/routes.py +163 -0
  26. semblance_databricks/services/deps.py +37 -0
  27. semblance_databricks/services/jobs/__init__.py +3 -0
  28. semblance_databricks/services/jobs/routes.py +168 -0
  29. semblance_databricks/services/permissions/__init__.py +5 -0
  30. semblance_databricks/services/permissions/routes.py +50 -0
  31. semblance_databricks/services/secrets/__init__.py +3 -0
  32. semblance_databricks/services/secrets/routes.py +53 -0
  33. semblance_databricks/services/workspace/__init__.py +3 -0
  34. semblance_databricks/services/workspace/routes.py +39 -0
  35. semblance_databricks/state.py +253 -0
  36. semblance_databricks/testing.py +66 -0
  37. semblance_databricks-0.1.0.dist-info/METADATA +126 -0
  38. semblance_databricks-0.1.0.dist-info/RECORD +42 -0
  39. semblance_databricks-0.1.0.dist-info/WHEEL +5 -0
  40. semblance_databricks-0.1.0.dist-info/entry_points.txt +2 -0
  41. semblance_databricks-0.1.0.dist-info/licenses/LICENSE.md +21 -0
  42. semblance_databricks-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,168 @@
1
+ """Jobs 2.2 (primary) and 2.1 representative aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Request
8
+
9
+ from semblance_databricks.errors import DatabricksError
10
+ from semblance_databricks.http import json_object
11
+ from semblance_databricks.pagination import paginate
12
+ from semblance_databricks.services.deps import mock_from, require_fail_stage
13
+ from semblance_databricks.state import JobRecord, RunRecord
14
+
15
+ _TERMINAL_RUNS = {"TERMINATED", "SKIPPED", "INTERNAL_ERROR"}
16
+
17
+
18
+ def _add_jobs_routes(router: APIRouter, prefix: str) -> None:
19
+ slug = prefix.replace("/", "_")
20
+
21
+ @router.get(f"{prefix}/jobs/list", name=f"list_jobs{slug}")
22
+ def list_jobs(
23
+ request: Request,
24
+ limit: int | None = None,
25
+ page_token: str | None = None,
26
+ ) -> dict[str, Any]:
27
+ mock = mock_from(request)
28
+ mock.state.maybe_tick_real(mock.config.clock)
29
+ jobs = sorted(mock.state.jobs.values(), key=lambda rec: rec.job_id)
30
+ items = [mock.state.job_json(job) for job in jobs]
31
+ page, nxt = paginate(
32
+ items,
33
+ page_size=limit,
34
+ page_token=page_token,
35
+ resource="jobs",
36
+ codec=mock.page_token_codec,
37
+ revision=mock.state.revision,
38
+ default_page_size=mock.config.default_page_size,
39
+ max_page_size=mock.config.max_page_size,
40
+ )
41
+ body: dict[str, Any] = {"jobs": page, "has_more": nxt is not None}
42
+ if nxt:
43
+ body["next_page_token"] = nxt
44
+ return body
45
+
46
+ @router.get(f"{prefix}/jobs/get", name=f"get_job{slug}")
47
+ def get_job(request: Request, job_id: str | None = None) -> dict[str, Any]:
48
+ mock = mock_from(request)
49
+ if not job_id:
50
+ raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "job_id required")
51
+ rec = mock.state.jobs.get(str(job_id))
52
+ if rec is None:
53
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Job not found")
54
+ return mock.state.job_json(rec)
55
+
56
+ @router.get(f"{prefix}/jobs/runs/get", name=f"get_run{slug}")
57
+ def get_run(request: Request, run_id: str | None = None) -> dict[str, Any]:
58
+ mock = mock_from(request)
59
+ mock.state.maybe_tick_real(mock.config.clock)
60
+ if not run_id:
61
+ raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "run_id required")
62
+ rec = mock.state.runs.get(str(run_id))
63
+ if rec is None:
64
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Run not found")
65
+ return mock.state.run_json(rec)
66
+
67
+ @router.post(f"{prefix}/jobs/create", name=f"create_job{slug}")
68
+ async def create_job(request: Request) -> dict[str, Any]:
69
+ require_fail_stage(request, "before_validate")
70
+ mock = mock_from(request)
71
+ body = await json_object(request)
72
+ require_fail_stage(request, "before_write")
73
+ jid = mock.state.allocate_numeric_id(mock.state.jobs, 1000)
74
+ if isinstance(body.get("settings"), dict):
75
+ settings = dict(body["settings"])
76
+ else:
77
+ settings = {key: value for key, value in body.items() if key != "job_id"}
78
+ if "name" not in settings:
79
+ settings["name"] = str(body.get("name") or "job")
80
+ mock.state.jobs[jid] = JobRecord(job_id=jid, settings=settings)
81
+ mock.state.permissions[("jobs", jid)] = {
82
+ "object_id": jid,
83
+ "object_type": "job",
84
+ "access_control_list": [],
85
+ }
86
+ mock.state.bump()
87
+ require_fail_stage(request, "after_write")
88
+ return {"job_id": int(jid)}
89
+
90
+ @router.post(f"{prefix}/jobs/reset", name=f"reset_job{slug}")
91
+ async def reset_job(request: Request) -> dict[str, Any]:
92
+ mock = mock_from(request)
93
+ body = await json_object(request)
94
+ jid = str(body.get("job_id", ""))
95
+ rec = mock.state.jobs.get(jid)
96
+ if rec is None:
97
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Job not found")
98
+ settings_raw = body.get("new_settings") or body.get("settings") or {}
99
+ if not isinstance(settings_raw, dict):
100
+ raise DatabricksError(
101
+ 400, "INVALID_PARAMETER_VALUE", "settings must be an object"
102
+ )
103
+ rec.settings = dict(settings_raw)
104
+ mock.state.bump()
105
+ return {}
106
+
107
+ @router.post(f"{prefix}/jobs/delete", name=f"delete_job{slug}")
108
+ async def delete_job(request: Request) -> dict[str, Any]:
109
+ mock = mock_from(request)
110
+ body = await json_object(request)
111
+ jid = str(body.get("job_id", ""))
112
+ if jid not in mock.state.jobs:
113
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Job not found")
114
+ del mock.state.jobs[jid]
115
+ mock.state.bump()
116
+ return {}
117
+
118
+ @router.post(f"{prefix}/jobs/runs/submit", name=f"submit_run{slug}")
119
+ async def submit_run(request: Request) -> dict[str, Any]:
120
+ mock = mock_from(request)
121
+ body = await json_object(request)
122
+ rid = mock.state.allocate_numeric_id(mock.state.runs, 2000)
123
+ job_id = str(body["job_id"]) if body.get("job_id") is not None else None
124
+ mock.state.runs[rid] = RunRecord(
125
+ run_id=rid,
126
+ job_id=job_id,
127
+ run_name=str(body.get("run_name", "submit")),
128
+ life_cycle_state="PENDING",
129
+ ticks_remaining=2,
130
+ output={"notebook_output": {"result": "pending"}},
131
+ )
132
+ mock.state.bump()
133
+ return {"run_id": int(rid)}
134
+
135
+ @router.post(f"{prefix}/jobs/runs/cancel", name=f"cancel_run{slug}")
136
+ async def cancel_run(request: Request) -> dict[str, Any]:
137
+ mock = mock_from(request)
138
+ body = await json_object(request)
139
+ rid = str(body.get("run_id", ""))
140
+ rec = mock.state.runs.get(rid)
141
+ if rec is None:
142
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Run not found")
143
+ if rec.life_cycle_state in _TERMINAL_RUNS:
144
+ raise DatabricksError(400, "INVALID_STATE", "Run is not active")
145
+ rec.canceling = True
146
+ rec.life_cycle_state = "TERMINATING"
147
+ mock.state.bump()
148
+ return {}
149
+
150
+ @router.get(f"{prefix}/jobs/runs/get-output", name=f"get_output{slug}")
151
+ def get_output(request: Request, run_id: str | None = None) -> dict[str, Any]:
152
+ mock = mock_from(request)
153
+ if not run_id:
154
+ raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "run_id required")
155
+ rec = mock.state.runs.get(str(run_id))
156
+ if rec is None:
157
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Run not found")
158
+ return {
159
+ "metadata": mock.state.run_json(rec),
160
+ "notebook_output": rec.output.get("notebook_output", {"result": ""}),
161
+ }
162
+
163
+
164
+ def create_jobs_router() -> APIRouter:
165
+ router = APIRouter()
166
+ _add_jobs_routes(router, "/api/2.2")
167
+ _add_jobs_routes(router, "/api/2.1")
168
+ return router
@@ -0,0 +1,5 @@
1
+ from semblance_databricks.services.permissions.routes import (
2
+ create_permissions_router,
3
+ )
4
+
5
+ __all__ = ["create_permissions_router"]
@@ -0,0 +1,50 @@
1
+ """Permissions GET/PATCH."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Request
8
+
9
+ from semblance_databricks.errors import DatabricksError
10
+ from semblance_databricks.http import json_object
11
+ from semblance_databricks.services.deps import mock_from
12
+
13
+
14
+ def _key(object_type: str, object_id: str) -> tuple[str, str]:
15
+ return (object_type, object_id)
16
+
17
+
18
+ def create_permissions_router() -> APIRouter:
19
+ router = APIRouter()
20
+
21
+ @router.get("/api/2.0/permissions/{object_type}/{object_id}")
22
+ def get_permissions(
23
+ request: Request, object_type: str, object_id: str
24
+ ) -> dict[str, Any]:
25
+ mock = mock_from(request)
26
+ rec = mock.state.permissions.get(_key(object_type, object_id))
27
+ if rec is None:
28
+ raise DatabricksError(
29
+ 404, "RESOURCE_DOES_NOT_EXIST", "Permissions not found"
30
+ )
31
+ return rec
32
+
33
+ @router.patch("/api/2.0/permissions/{object_type}/{object_id}")
34
+ async def patch_permissions(
35
+ request: Request, object_type: str, object_id: str
36
+ ) -> dict[str, Any]:
37
+ mock = mock_from(request)
38
+ rec = mock.state.permissions.get(_key(object_type, object_id))
39
+ if rec is None:
40
+ raise DatabricksError(
41
+ 404, "RESOURCE_DOES_NOT_EXIST", "Permissions not found"
42
+ )
43
+ body = await json_object(request)
44
+ acl = body.get("access_control_list")
45
+ if isinstance(acl, list):
46
+ rec["access_control_list"] = acl
47
+ mock.state.bump()
48
+ return rec
49
+
50
+ return router
@@ -0,0 +1,3 @@
1
+ from semblance_databricks.services.secrets.routes import create_secrets_router
2
+
3
+ __all__ = ["create_secrets_router"]
@@ -0,0 +1,53 @@
1
+ """Workspace secrets metadata (values never returned)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Request
8
+
9
+ from semblance_databricks.errors import DatabricksError
10
+ from semblance_databricks.http import json_object
11
+ from semblance_databricks.services.deps import mock_from
12
+ from semblance_databricks.state import SecretScope
13
+
14
+
15
+ def create_secrets_router() -> APIRouter:
16
+ router = APIRouter()
17
+
18
+ @router.get("/api/2.0/secrets/scopes/list")
19
+ def list_scopes(request: Request) -> dict[str, Any]:
20
+ mock = mock_from(request)
21
+ return {"scopes": [{"name": s.name} for s in mock.state.secret_scopes.values()]}
22
+
23
+ @router.get("/api/2.0/secrets/list")
24
+ def list_secrets(request: Request, scope: str | None = None) -> dict[str, Any]:
25
+ mock = mock_from(request)
26
+ if not scope:
27
+ raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "scope required")
28
+ rec = mock.state.secret_scopes.get(scope)
29
+ if rec is None:
30
+ raise DatabricksError(404, "RESOURCE_DOES_NOT_EXIST", "Scope not found")
31
+ return {
32
+ "secrets": [
33
+ {"key": key, "last_updated_timestamp": 1_700_000_000_000}
34
+ for key in rec.keys
35
+ ]
36
+ }
37
+
38
+ @router.post("/api/2.0/secrets/put")
39
+ async def put_secret(request: Request) -> dict[str, Any]:
40
+ mock = mock_from(request)
41
+ body = await json_object(request)
42
+ scope = str(body.get("scope", ""))
43
+ key = str(body.get("key", ""))
44
+ if not scope or not key:
45
+ raise DatabricksError(
46
+ 400, "INVALID_PARAMETER_VALUE", "scope and key required"
47
+ )
48
+ rec = mock.state.secret_scopes.setdefault(scope, SecretScope(name=scope))
49
+ rec.keys[key] = str(body.get("string_value", ""))
50
+ mock.state.bump()
51
+ return {}
52
+
53
+ return router
@@ -0,0 +1,3 @@
1
+ from semblance_databricks.services.workspace.routes import create_workspace_router
2
+
3
+ __all__ = ["create_workspace_router"]
@@ -0,0 +1,39 @@
1
+ """Workspace get-status and current user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Request
8
+
9
+ from semblance_databricks.errors import DatabricksError
10
+ from semblance_databricks.services.deps import mock_from
11
+
12
+
13
+ def create_workspace_router() -> APIRouter:
14
+ router = APIRouter()
15
+
16
+ @router.get("/api/2.0/workspace/get-status")
17
+ def get_status(request: Request, path: str | None = None) -> dict[str, Any]:
18
+ mock = mock_from(request)
19
+ if not path:
20
+ raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "path required")
21
+ obj = mock.state.workspace_objects.get(path)
22
+ if obj is None:
23
+ raise DatabricksError(
24
+ 404,
25
+ "RESOURCE_DOES_NOT_EXIST",
26
+ f"Path {path} does not exist",
27
+ )
28
+ return {
29
+ "path": obj.path,
30
+ "object_type": obj.object_type,
31
+ "language": obj.language,
32
+ }
33
+
34
+ @router.get("/api/2.0/preview/scim/v2/Me")
35
+ def me(request: Request) -> dict[str, Any]:
36
+ mock = mock_from(request)
37
+ return dict(mock.state.user)
38
+
39
+ return router
@@ -0,0 +1,253 @@
1
+ """Process-local Databricks workspace graph and virtual clock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from semblance_databricks.ids import make_id
10
+
11
+
12
+ @dataclass
13
+ class ClusterRecord:
14
+ cluster_id: str
15
+ cluster_name: str
16
+ spark_version: str
17
+ node_type_id: str
18
+ state: str
19
+ state_message: str = ""
20
+ num_workers: int = 1
21
+ ticks_remaining: int = 0
22
+ fail_after: bool = False
23
+ libraries: list[dict[str, Any]] = field(default_factory=list)
24
+ events: list[dict[str, Any]] = field(default_factory=list)
25
+ deleted: bool = False
26
+
27
+
28
+ @dataclass
29
+ class JobRecord:
30
+ job_id: str
31
+ settings: dict[str, Any]
32
+ created_time: int = 1_700_000_000_000
33
+ creator_user_name: str = "user@acme.example"
34
+
35
+
36
+ @dataclass
37
+ class RunRecord:
38
+ run_id: str
39
+ job_id: str | None
40
+ run_name: str
41
+ life_cycle_state: str
42
+ result_state: str | None = None
43
+ ticks_remaining: int = 0
44
+ output: dict[str, Any] = field(default_factory=dict)
45
+ canceling: bool = False
46
+
47
+
48
+ @dataclass
49
+ class WarehouseRecord:
50
+ id: str
51
+ name: str
52
+ state: str = "RUNNING"
53
+ cluster_size: str = "X-Small"
54
+
55
+
56
+ @dataclass
57
+ class SecretScope:
58
+ name: str
59
+ keys: dict[str, str] = field(default_factory=dict)
60
+
61
+
62
+ @dataclass
63
+ class DbfsNode:
64
+ path: str
65
+ is_dir: bool
66
+ data: bytes = b""
67
+
68
+
69
+ @dataclass
70
+ class WorkspaceObject:
71
+ path: str
72
+ object_type: str = "NOTEBOOK"
73
+ language: str = "PYTHON"
74
+
75
+
76
+ class DatabricksState:
77
+ def __init__(self, seed: int | None = 42) -> None:
78
+ self.seed = seed
79
+ self.revision = 0
80
+ self.seq = 0
81
+ self.clusters: dict[str, ClusterRecord] = {}
82
+ self.jobs: dict[str, JobRecord] = {}
83
+ self.runs: dict[str, RunRecord] = {}
84
+ self.warehouses: dict[str, WarehouseRecord] = {}
85
+ self.secret_scopes: dict[str, SecretScope] = {}
86
+ self.dbfs: dict[str, DbfsNode] = {}
87
+ self.workspace_objects: dict[str, WorkspaceObject] = {}
88
+ self.permissions: dict[tuple[str, str], dict[str, Any]] = {}
89
+ self.statements: dict[str, dict[str, Any]] = {}
90
+ self.statement_callbacks: dict[str, Callable[..., Any]] = {}
91
+ self.user = {
92
+ "id": "1001",
93
+ "userName": "user@acme.example",
94
+ "displayName": "Acme User",
95
+ "active": True,
96
+ }
97
+
98
+ def bump(self) -> None:
99
+ self.revision += 1
100
+
101
+ def next_seq(self) -> int:
102
+ self.seq += 1
103
+ return self.seq
104
+
105
+ def allocate_numeric_id(self, existing: dict[str, Any], floor: int) -> str:
106
+ used = {int(key) for key in existing if str(key).isdigit()}
107
+ n = max([floor - 1, *used]) + 1
108
+ while str(n) in existing:
109
+ n += 1
110
+ self.seq = max(self.seq, n)
111
+ return str(n)
112
+
113
+ def reindex_seq(self) -> None:
114
+ nums = [int(k) for k in self.jobs if str(k).isdigit()]
115
+ nums.extend(int(k) for k in self.runs if str(k).isdigit())
116
+ if nums:
117
+ self.seq = max(self.seq, max(nums))
118
+
119
+ def clear(self) -> None:
120
+ self.clusters.clear()
121
+ self.jobs.clear()
122
+ self.runs.clear()
123
+ self.warehouses.clear()
124
+ self.secret_scopes.clear()
125
+ self.dbfs.clear()
126
+ self.workspace_objects.clear()
127
+ self.permissions.clear()
128
+ self.statements.clear()
129
+ self.statement_callbacks.clear()
130
+ self.revision = 0
131
+ self.seq = 0
132
+
133
+ def tick(self) -> None:
134
+ for cluster in self.clusters.values():
135
+ if cluster.deleted:
136
+ continue
137
+ if cluster.state in {"PENDING", "RESTARTING"}:
138
+ if cluster.ticks_remaining > 0:
139
+ cluster.ticks_remaining -= 1
140
+ if cluster.ticks_remaining <= 0:
141
+ if cluster.state == "RESTARTING":
142
+ cluster.state = "PENDING"
143
+ cluster.ticks_remaining = 1
144
+ cluster.events.append(
145
+ {"type": "PENDING", "message": "Restarting"}
146
+ )
147
+ elif cluster.fail_after:
148
+ cluster.state = "ERROR"
149
+ cluster.state_message = "Fixture startup failure"
150
+ cluster.events.append(
151
+ {"type": "ERROR", "message": cluster.state_message}
152
+ )
153
+ else:
154
+ cluster.state = "RUNNING"
155
+ cluster.state_message = ""
156
+ cluster.events.append({"type": "RUNNING", "message": "Running"})
157
+ elif cluster.state == "TERMINATING":
158
+ cluster.state = "TERMINATED"
159
+ cluster.events.append({"type": "TERMINATED", "message": "Terminated"})
160
+ for run in self.runs.values():
161
+ if run.canceling:
162
+ if run.life_cycle_state == "TERMINATING":
163
+ run.life_cycle_state = "TERMINATED"
164
+ run.result_state = "CANCELED"
165
+ run.canceling = False
166
+ else:
167
+ run.life_cycle_state = "TERMINATING"
168
+ continue
169
+ if (
170
+ run.life_cycle_state in {"PENDING", "RUNNING"}
171
+ and run.ticks_remaining > 0
172
+ ):
173
+ run.ticks_remaining -= 1
174
+ if run.life_cycle_state == "PENDING":
175
+ run.life_cycle_state = "RUNNING"
176
+ run.ticks_remaining = max(run.ticks_remaining, 1)
177
+ elif run.ticks_remaining <= 0:
178
+ run.life_cycle_state = "TERMINATED"
179
+ run.result_state = run.result_state or "SUCCESS"
180
+
181
+ def maybe_tick_real(self, clock: str) -> None:
182
+ if clock == "real":
183
+ self.tick()
184
+
185
+ def add_cluster(
186
+ self,
187
+ cluster_name: str,
188
+ *,
189
+ cluster_id: str | None = None,
190
+ spark_version: str = "13.3.x-scala2.12",
191
+ node_type_id: str = "i3.xlarge",
192
+ state: str = "RUNNING",
193
+ ticks_remaining: int = 0,
194
+ fail_after: bool = False,
195
+ num_workers: int = 1,
196
+ ) -> ClusterRecord:
197
+ cid = cluster_id or make_id(
198
+ "cluster", f"{cluster_name}:{self.next_seq()}", self.seed
199
+ )
200
+ if cid in self.clusters:
201
+ raise ValueError(f"Duplicate cluster_id {cid}")
202
+ rec = ClusterRecord(
203
+ cluster_id=cid,
204
+ cluster_name=cluster_name,
205
+ spark_version=spark_version,
206
+ node_type_id=node_type_id,
207
+ state=state,
208
+ ticks_remaining=ticks_remaining,
209
+ fail_after=fail_after,
210
+ num_workers=num_workers,
211
+ )
212
+ rec.events.append({"type": state, "message": f"Cluster {state}"})
213
+ self.clusters[cid] = rec
214
+ self.permissions[("clusters", cid)] = {
215
+ "object_id": cid,
216
+ "object_type": "clusters",
217
+ "access_control_list": [],
218
+ }
219
+ self.bump()
220
+ return rec
221
+
222
+ def cluster_json(self, rec: ClusterRecord) -> dict[str, Any]:
223
+ return {
224
+ "cluster_id": rec.cluster_id,
225
+ "cluster_name": rec.cluster_name,
226
+ "spark_version": rec.spark_version,
227
+ "node_type_id": rec.node_type_id,
228
+ "state": rec.state,
229
+ "state_message": rec.state_message,
230
+ "num_workers": rec.num_workers,
231
+ "libraries": rec.libraries,
232
+ }
233
+
234
+ def job_json(self, rec: JobRecord) -> dict[str, Any]:
235
+ return {
236
+ "job_id": int(rec.job_id) if rec.job_id.isdigit() else rec.job_id,
237
+ "created_time": rec.created_time,
238
+ "creator_user_name": rec.creator_user_name,
239
+ "settings": rec.settings,
240
+ }
241
+
242
+ def run_json(self, rec: RunRecord) -> dict[str, Any]:
243
+ state: dict[str, Any] = {"life_cycle_state": rec.life_cycle_state}
244
+ if rec.result_state:
245
+ state["result_state"] = rec.result_state
246
+ body: dict[str, Any] = {
247
+ "run_id": int(rec.run_id) if rec.run_id.isdigit() else rec.run_id,
248
+ "run_name": rec.run_name,
249
+ "state": state,
250
+ }
251
+ if rec.job_id:
252
+ body["job_id"] = int(rec.job_id) if rec.job_id.isdigit() else rec.job_id
253
+ return body
@@ -0,0 +1,66 @@
1
+ """Pytest / in-process helpers. Prefer TestClient; no live Databricks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from fastapi.testclient import TestClient
10
+
11
+ from semblance_databricks.app import DatabricksMock
12
+ from semblance_databricks.config import DatabricksMockConfig
13
+ from semblance_databricks.fixtures.loaders import bundled_acme_path
14
+
15
+
16
+ class DatabricksMockContext:
17
+ """Load a fixture, yield a TestClient, reset state on exit."""
18
+
19
+ def __init__(
20
+ self,
21
+ *,
22
+ seed: int | None = 42,
23
+ fixture: str | Path | None = None,
24
+ config: DatabricksMockConfig | None = None,
25
+ ) -> None:
26
+ if config is None:
27
+ config = DatabricksMockConfig(seed=seed)
28
+ self.mock = DatabricksMock(config)
29
+ path = fixture if fixture is not None else bundled_acme_path()
30
+ self.mock.load_fixture(path)
31
+ self._client: TestClient | None = None
32
+
33
+ def __enter__(self) -> TestClient:
34
+ self._client = TestClient(self.mock.as_fastapi())
35
+ return self._client
36
+
37
+ def __exit__(self, *exc: Any) -> None:
38
+ self.mock.state.clear()
39
+ if self._client is not None:
40
+ self._client.close()
41
+
42
+
43
+ def databricks_test_client(
44
+ mock: DatabricksMock | None = None,
45
+ *,
46
+ fixture: str | Path | None = None,
47
+ config: DatabricksMockConfig | None = None,
48
+ ) -> TestClient:
49
+ """Build a TestClient for a DatabricksMock (loads bundled acme if needed)."""
50
+ if mock is None:
51
+ mock = DatabricksMock(config or DatabricksMockConfig())
52
+ mock.load_fixture(fixture if fixture is not None else bundled_acme_path())
53
+ return TestClient(mock.as_fastapi())
54
+
55
+
56
+ def acme_client_iter(
57
+ config: DatabricksMockConfig | None = None,
58
+ ) -> Iterator[tuple[DatabricksMock, TestClient]]:
59
+ mock = DatabricksMock(config or DatabricksMockConfig(seed=42))
60
+ mock.load_fixture(bundled_acme_path())
61
+ client = TestClient(mock.as_fastapi())
62
+ try:
63
+ yield mock, client
64
+ finally:
65
+ mock.state.clear()
66
+ client.close()