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.
- semblance_databricks/__init__.py +19 -0
- semblance_databricks/app.py +104 -0
- semblance_databricks/auth.py +145 -0
- semblance_databricks/cli.py +112 -0
- semblance_databricks/compatibility.py +30 -0
- semblance_databricks/compatibility.yaml +269 -0
- semblance_databricks/config.py +52 -0
- semblance_databricks/errors.py +72 -0
- semblance_databricks/fixtures/__init__.py +5 -0
- semblance_databricks/fixtures/defaults/__init__.py +0 -0
- semblance_databricks/fixtures/defaults/acme.yaml +47 -0
- semblance_databricks/fixtures/loaders.py +196 -0
- semblance_databricks/http.py +34 -0
- semblance_databricks/ids.py +88 -0
- semblance_databricks/models/__init__.py +1 -0
- semblance_databricks/pagination.py +48 -0
- semblance_databricks/py.typed +0 -0
- semblance_databricks/registry.py +29 -0
- semblance_databricks/services/__init__.py +1 -0
- semblance_databricks/services/clusters/__init__.py +3 -0
- semblance_databricks/services/clusters/routes.py +191 -0
- semblance_databricks/services/dbfs/__init__.py +3 -0
- semblance_databricks/services/dbfs/routes.py +126 -0
- semblance_databricks/services/dbsql/__init__.py +3 -0
- semblance_databricks/services/dbsql/routes.py +163 -0
- semblance_databricks/services/deps.py +37 -0
- semblance_databricks/services/jobs/__init__.py +3 -0
- semblance_databricks/services/jobs/routes.py +168 -0
- semblance_databricks/services/permissions/__init__.py +5 -0
- semblance_databricks/services/permissions/routes.py +50 -0
- semblance_databricks/services/secrets/__init__.py +3 -0
- semblance_databricks/services/secrets/routes.py +53 -0
- semblance_databricks/services/workspace/__init__.py +3 -0
- semblance_databricks/services/workspace/routes.py +39 -0
- semblance_databricks/state.py +253 -0
- semblance_databricks/testing.py +66 -0
- semblance_databricks-0.1.0.dist-info/METADATA +126 -0
- semblance_databricks-0.1.0.dist-info/RECORD +42 -0
- semblance_databricks-0.1.0.dist-info/WHEEL +5 -0
- semblance_databricks-0.1.0.dist-info/entry_points.txt +2 -0
- semblance_databricks-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- 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,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,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,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()
|