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,52 @@
|
|
|
1
|
+
"""Typed configuration for DatabricksMock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
AuthMode = Literal["disabled", "optional", "strict"]
|
|
9
|
+
ClockMode = Literal["virtual", "real"]
|
|
10
|
+
FailStage = Literal["before_validate", "before_write", "after_write"] | None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TokenGrant:
|
|
15
|
+
"""A token accepted in strict auth mode. Never log or echo ``token``."""
|
|
16
|
+
|
|
17
|
+
token: str
|
|
18
|
+
scopes: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class DatabricksMockConfig:
|
|
23
|
+
"""Runtime options for a Databricks mock server."""
|
|
24
|
+
|
|
25
|
+
seed: int | None = 42
|
|
26
|
+
auth: AuthMode = "optional"
|
|
27
|
+
tokens: tuple[TokenGrant, ...] = ()
|
|
28
|
+
error_rate: float = 0.0
|
|
29
|
+
error_codes: list[int] | None = None
|
|
30
|
+
latency_ms: float = 0.0
|
|
31
|
+
jitter_ms: float = 0.0
|
|
32
|
+
rate_limit: float | None = None
|
|
33
|
+
default_page_size: int = 20
|
|
34
|
+
max_page_size: int = 100
|
|
35
|
+
clock: ClockMode = "virtual"
|
|
36
|
+
org_id: str | None = "1234567890"
|
|
37
|
+
fail_stage: FailStage = None
|
|
38
|
+
dbfs_temp_dir: str | None = None
|
|
39
|
+
dbfs_max_files: int = 100
|
|
40
|
+
dbfs_max_bytes: int = 1_048_576
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if self.error_codes is None:
|
|
44
|
+
self.error_codes = [500]
|
|
45
|
+
if self.auth not in ("disabled", "optional", "strict"):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"auth must be 'disabled', 'optional', or 'strict', got {self.auth!r}"
|
|
48
|
+
)
|
|
49
|
+
if self.clock not in ("virtual", "real"):
|
|
50
|
+
raise ValueError("clock must be 'virtual' or 'real'")
|
|
51
|
+
if not 0.0 <= self.error_rate <= 1.0:
|
|
52
|
+
raise ValueError("error_rate must be between 0 and 1")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Databricks error_code/message mapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Request
|
|
8
|
+
from fastapi.exceptions import RequestValidationError
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabricksError(Exception):
|
|
14
|
+
"""Raised to produce a Databricks-style error response."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status_code: int, error_code: str, message: str) -> None:
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.error_code = error_code
|
|
19
|
+
self.message = message
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
|
|
22
|
+
def to_body(self) -> dict[str, str]:
|
|
23
|
+
return {"error_code": self.error_code, "message": self.message}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def databricks_error_body(error_code: str, message: str) -> dict[str, str]:
|
|
27
|
+
return {"error_code": error_code, "message": message}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_exception_handlers(app: Any) -> None:
|
|
31
|
+
@app.exception_handler(DatabricksError)
|
|
32
|
+
async def databricks_error_handler(
|
|
33
|
+
request: Request, exc: DatabricksError
|
|
34
|
+
) -> JSONResponse:
|
|
35
|
+
return JSONResponse(status_code=exc.status_code, content=exc.to_body())
|
|
36
|
+
|
|
37
|
+
@app.exception_handler(RequestValidationError)
|
|
38
|
+
async def validation_handler(
|
|
39
|
+
request: Request, exc: RequestValidationError
|
|
40
|
+
) -> JSONResponse:
|
|
41
|
+
return JSONResponse(
|
|
42
|
+
status_code=400,
|
|
43
|
+
content=databricks_error_body(
|
|
44
|
+
"INVALID_PARAMETER_VALUE",
|
|
45
|
+
"Invalid request",
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@app.exception_handler(StarletteHTTPException)
|
|
50
|
+
async def http_exception_handler(
|
|
51
|
+
request: Request, exc: StarletteHTTPException
|
|
52
|
+
) -> JSONResponse:
|
|
53
|
+
if exc.status_code == 404:
|
|
54
|
+
return JSONResponse(
|
|
55
|
+
status_code=404,
|
|
56
|
+
content=databricks_error_body(
|
|
57
|
+
"RESOURCE_DOES_NOT_EXIST",
|
|
58
|
+
f"Path {request.url.path} does not exist",
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
if exc.status_code == 405:
|
|
62
|
+
return JSONResponse(
|
|
63
|
+
status_code=405,
|
|
64
|
+
content=databricks_error_body(
|
|
65
|
+
"INVALID_PARAMETER_VALUE",
|
|
66
|
+
"Method not allowed",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
return JSONResponse(
|
|
70
|
+
status_code=exc.status_code,
|
|
71
|
+
content=databricks_error_body("UNKNOWN", "HTTP error"),
|
|
72
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
workspace:
|
|
3
|
+
workspaceId: "1234567890"
|
|
4
|
+
name: acme
|
|
5
|
+
clusters:
|
|
6
|
+
- cluster_id: "0101-acme-0001"
|
|
7
|
+
cluster_name: ingest-1
|
|
8
|
+
state: RUNNING
|
|
9
|
+
- cluster_id: "0101-acme-0002"
|
|
10
|
+
cluster_name: ingest-2
|
|
11
|
+
state: RUNNING
|
|
12
|
+
- cluster_id: "0101-acme-0003"
|
|
13
|
+
cluster_name: ingest-3
|
|
14
|
+
state: TERMINATED
|
|
15
|
+
- cluster_id: "0101-acme-0004"
|
|
16
|
+
cluster_name: ingest-4
|
|
17
|
+
state: PENDING
|
|
18
|
+
startup_delay_ticks: 1
|
|
19
|
+
jobs:
|
|
20
|
+
- job_id: "1001"
|
|
21
|
+
name: nightly-etl
|
|
22
|
+
- job_id: "1002"
|
|
23
|
+
name: weekly-report
|
|
24
|
+
runs:
|
|
25
|
+
- run_id: "2001"
|
|
26
|
+
job_id: "1001"
|
|
27
|
+
run_name: nightly-etl-run
|
|
28
|
+
life_cycle_state: TERMINATED
|
|
29
|
+
result_state: SUCCESS
|
|
30
|
+
output_notebook: "ok"
|
|
31
|
+
warehouses:
|
|
32
|
+
- id: "wh-acme"
|
|
33
|
+
name: analytics
|
|
34
|
+
state: RUNNING
|
|
35
|
+
secretScopes:
|
|
36
|
+
- name: acme-scope
|
|
37
|
+
keys:
|
|
38
|
+
- api-key
|
|
39
|
+
dbfs:
|
|
40
|
+
- path: "/mnt/acme"
|
|
41
|
+
is_dir: true
|
|
42
|
+
- path: "/mnt/acme/readme.txt"
|
|
43
|
+
is_dir: false
|
|
44
|
+
content: hello
|
|
45
|
+
workspaceObjects:
|
|
46
|
+
- path: "/Users/user@acme.example/notebook"
|
|
47
|
+
object_type: NOTEBOOK
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Fixture v1 schema and loaders. YAML/JSON is data only — never evaluated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
from semblance_databricks.state import (
|
|
12
|
+
DatabricksState,
|
|
13
|
+
DbfsNode,
|
|
14
|
+
JobRecord,
|
|
15
|
+
RunRecord,
|
|
16
|
+
SecretScope,
|
|
17
|
+
WarehouseRecord,
|
|
18
|
+
WorkspaceObject,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Strict(BaseModel):
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FixtureWorkspace(_Strict):
|
|
27
|
+
workspace_id: str = Field(alias="workspaceId", default="1234567890")
|
|
28
|
+
name: str = "acme"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FixtureCluster(_Strict):
|
|
32
|
+
cluster_id: str | None = Field(default=None, alias="cluster_id")
|
|
33
|
+
cluster_name: str
|
|
34
|
+
spark_version: str = "13.3.x-scala2.12"
|
|
35
|
+
node_type_id: str = "i3.xlarge"
|
|
36
|
+
state: str = "RUNNING"
|
|
37
|
+
num_workers: int = 1
|
|
38
|
+
startup_delay_ticks: int = 0
|
|
39
|
+
startup_failure: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FixtureJob(_Strict):
|
|
43
|
+
job_id: str | None = None
|
|
44
|
+
name: str
|
|
45
|
+
creator_user_name: str = "user@acme.example"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FixtureRun(_Strict):
|
|
49
|
+
run_id: str | None = None
|
|
50
|
+
job_id: str | None = None
|
|
51
|
+
run_name: str = "nightly"
|
|
52
|
+
life_cycle_state: str = "TERMINATED"
|
|
53
|
+
result_state: str | None = "SUCCESS"
|
|
54
|
+
output_notebook: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FixtureWarehouse(_Strict):
|
|
58
|
+
id: str | None = None
|
|
59
|
+
name: str
|
|
60
|
+
state: str = "RUNNING"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FixtureSecretScope(_Strict):
|
|
64
|
+
name: str
|
|
65
|
+
keys: list[str] = Field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FixtureDbfs(_Strict):
|
|
69
|
+
path: str
|
|
70
|
+
is_dir: bool = False
|
|
71
|
+
content: str = ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FixtureWorkspaceObject(_Strict):
|
|
75
|
+
path: str
|
|
76
|
+
object_type: str = "NOTEBOOK"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FixtureDocument(_Strict):
|
|
80
|
+
version: int
|
|
81
|
+
workspace: FixtureWorkspace = Field(default_factory=FixtureWorkspace)
|
|
82
|
+
clusters: list[FixtureCluster] = Field(default_factory=list)
|
|
83
|
+
jobs: list[FixtureJob] = Field(default_factory=list)
|
|
84
|
+
runs: list[FixtureRun] = Field(default_factory=list)
|
|
85
|
+
warehouses: list[FixtureWarehouse] = Field(default_factory=list)
|
|
86
|
+
secret_scopes: list[FixtureSecretScope] = Field(
|
|
87
|
+
default_factory=list, alias="secretScopes"
|
|
88
|
+
)
|
|
89
|
+
dbfs: list[FixtureDbfs] = Field(default_factory=list)
|
|
90
|
+
workspace_objects: list[FixtureWorkspaceObject] = Field(
|
|
91
|
+
default_factory=list, alias="workspaceObjects"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_fixture(data: dict[str, Any]) -> FixtureDocument:
|
|
96
|
+
doc = FixtureDocument.model_validate(data)
|
|
97
|
+
if doc.version != 1:
|
|
98
|
+
raise ValueError("only fixture version 1 is supported")
|
|
99
|
+
return doc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_fixture_file(path: str | Path) -> FixtureDocument:
|
|
103
|
+
raw = Path(path).read_text(encoding="utf-8")
|
|
104
|
+
if Path(path).suffix.lower() in {".yaml", ".yml"}:
|
|
105
|
+
loaded = yaml.safe_load(raw)
|
|
106
|
+
else:
|
|
107
|
+
import json
|
|
108
|
+
|
|
109
|
+
loaded = json.loads(raw)
|
|
110
|
+
if not isinstance(loaded, dict):
|
|
111
|
+
raise ValueError("fixture root must be a mapping")
|
|
112
|
+
return parse_fixture(loaded)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def apply_fixture(doc: FixtureDocument, state: DatabricksState) -> None:
|
|
116
|
+
seen_jobs: set[str] = set()
|
|
117
|
+
for cluster in doc.clusters:
|
|
118
|
+
rec = state.add_cluster(
|
|
119
|
+
cluster.cluster_name,
|
|
120
|
+
cluster_id=cluster.cluster_id,
|
|
121
|
+
spark_version=cluster.spark_version,
|
|
122
|
+
node_type_id=cluster.node_type_id,
|
|
123
|
+
state=cluster.state,
|
|
124
|
+
ticks_remaining=cluster.startup_delay_ticks,
|
|
125
|
+
fail_after=cluster.startup_failure,
|
|
126
|
+
num_workers=cluster.num_workers,
|
|
127
|
+
)
|
|
128
|
+
_ = rec
|
|
129
|
+
for job in doc.jobs:
|
|
130
|
+
jid = job.job_id or str(1000 + len(state.jobs) + 1)
|
|
131
|
+
if jid in seen_jobs or jid in state.jobs:
|
|
132
|
+
raise ValueError(f"Duplicate job_id {jid}")
|
|
133
|
+
seen_jobs.add(jid)
|
|
134
|
+
state.jobs[jid] = JobRecord(
|
|
135
|
+
job_id=jid,
|
|
136
|
+
settings={"name": job.name},
|
|
137
|
+
creator_user_name=job.creator_user_name,
|
|
138
|
+
)
|
|
139
|
+
state.permissions[("jobs", jid)] = {
|
|
140
|
+
"object_id": jid,
|
|
141
|
+
"object_type": "job",
|
|
142
|
+
"access_control_list": [
|
|
143
|
+
{
|
|
144
|
+
"user_name": job.creator_user_name,
|
|
145
|
+
"all_permissions": [{"permission_level": "CAN_MANAGE"}],
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
}
|
|
149
|
+
for run in doc.runs:
|
|
150
|
+
rid = run.run_id or str(2000 + len(state.runs) + 1)
|
|
151
|
+
if rid in state.runs:
|
|
152
|
+
raise ValueError(f"Duplicate run_id {rid}")
|
|
153
|
+
if run.job_id and run.job_id not in state.jobs:
|
|
154
|
+
raise ValueError(f"Dangling job_id {run.job_id} on run")
|
|
155
|
+
state.runs[rid] = RunRecord(
|
|
156
|
+
run_id=rid,
|
|
157
|
+
job_id=run.job_id,
|
|
158
|
+
run_name=run.run_name,
|
|
159
|
+
life_cycle_state=run.life_cycle_state,
|
|
160
|
+
result_state=run.result_state,
|
|
161
|
+
output={"notebook_output": {"result": run.output_notebook or "ok"}},
|
|
162
|
+
)
|
|
163
|
+
for wh in doc.warehouses:
|
|
164
|
+
wid = wh.id or f"wh-{len(state.warehouses) + 1}"
|
|
165
|
+
if wid in state.warehouses:
|
|
166
|
+
raise ValueError(f"Duplicate warehouse id {wid}")
|
|
167
|
+
state.warehouses[wid] = WarehouseRecord(id=wid, name=wh.name, state=wh.state)
|
|
168
|
+
for scope in doc.secret_scopes:
|
|
169
|
+
if scope.name in state.secret_scopes:
|
|
170
|
+
raise ValueError(f"Duplicate secret scope {scope.name}")
|
|
171
|
+
state.secret_scopes[scope.name] = SecretScope(
|
|
172
|
+
name=scope.name,
|
|
173
|
+
keys={k: "redacted" for k in scope.keys},
|
|
174
|
+
)
|
|
175
|
+
for node in doc.dbfs:
|
|
176
|
+
state.dbfs[node.path] = DbfsNode(
|
|
177
|
+
path=node.path,
|
|
178
|
+
is_dir=node.is_dir,
|
|
179
|
+
data=node.content.encode(),
|
|
180
|
+
)
|
|
181
|
+
for obj in doc.workspace_objects:
|
|
182
|
+
state.workspace_objects[obj.path] = WorkspaceObject(
|
|
183
|
+
path=obj.path, object_type=obj.object_type
|
|
184
|
+
)
|
|
185
|
+
state.reindex_seq()
|
|
186
|
+
state.bump()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def bundled_acme_path() -> Path:
|
|
190
|
+
return Path(__file__).resolve().parent / "defaults" / "acme.yaml"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_bundled_fixture(name: str = "acme") -> FixtureDocument:
|
|
194
|
+
if name != "acme":
|
|
195
|
+
raise ValueError(f"unknown bundled fixture {name!r}; expected 'acme'")
|
|
196
|
+
return load_fixture_file(bundled_acme_path())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared request parsing for Databricks REST handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import Request
|
|
9
|
+
|
|
10
|
+
from semblance_databricks.errors import DatabricksError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def json_object(request: Request) -> dict[str, Any]:
|
|
14
|
+
raw = await request.body()
|
|
15
|
+
if not raw:
|
|
16
|
+
return {}
|
|
17
|
+
try:
|
|
18
|
+
body = json.loads(raw)
|
|
19
|
+
except json.JSONDecodeError as exc:
|
|
20
|
+
raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "Invalid JSON") from exc
|
|
21
|
+
if not isinstance(body, dict):
|
|
22
|
+
raise DatabricksError(400, "INVALID_PARAMETER_VALUE", "JSON object required")
|
|
23
|
+
return body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_int(value: Any, default: int, name: str) -> int:
|
|
27
|
+
if value is None:
|
|
28
|
+
return default
|
|
29
|
+
try:
|
|
30
|
+
return int(value)
|
|
31
|
+
except (TypeError, ValueError) as exc:
|
|
32
|
+
raise DatabricksError(
|
|
33
|
+
400, "INVALID_PARAMETER_VALUE", f"{name} must be an integer"
|
|
34
|
+
) from exc
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Deterministic resource IDs and opaque page tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from semblance_databricks.errors import DatabricksError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def token_secret(seed: int | None) -> bytes:
|
|
15
|
+
material = f"semblance-databricks:{seed if seed is not None else 'none'}"
|
|
16
|
+
return hashlib.sha256(material.encode()).digest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_id(kind: str, identity: str, seed: int | None = 42) -> str:
|
|
20
|
+
digest = hashlib.sha256(
|
|
21
|
+
f"semblance-databricks:{seed}:{kind}:{identity}".encode()
|
|
22
|
+
).hexdigest()[:12]
|
|
23
|
+
if kind == "cluster":
|
|
24
|
+
return f"0101-{digest[:8]}-{digest[8:]}"
|
|
25
|
+
if kind == "job":
|
|
26
|
+
return str(int(digest[:8], 16) % 10_000_000 + 1000)
|
|
27
|
+
if kind == "run":
|
|
28
|
+
return str(int(digest[:8], 16) % 10_000_000 + 2000)
|
|
29
|
+
if kind == "warehouse":
|
|
30
|
+
return digest
|
|
31
|
+
return digest
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class PageCursor:
|
|
36
|
+
resource: str
|
|
37
|
+
offset: int
|
|
38
|
+
revision: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PageTokenCodec:
|
|
42
|
+
"""URL-safe checksummed page tokens. Opaque to clients."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, secret: bytes) -> None:
|
|
45
|
+
self._secret = secret
|
|
46
|
+
|
|
47
|
+
def encode(self, resource: str, offset: int, revision: int) -> str:
|
|
48
|
+
payload = json.dumps(
|
|
49
|
+
{"r": resource, "o": offset, "v": revision},
|
|
50
|
+
separators=(",", ":"),
|
|
51
|
+
)
|
|
52
|
+
sig = hmac.new(self._secret, payload.encode(), hashlib.sha256).hexdigest()[:16]
|
|
53
|
+
raw = f"{payload}|{sig}".encode()
|
|
54
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
55
|
+
|
|
56
|
+
def decode(self, token: str, expected_resource: str) -> PageCursor:
|
|
57
|
+
try:
|
|
58
|
+
padded = token + "=" * (-len(token) % 4)
|
|
59
|
+
decoded = base64.urlsafe_b64decode(padded.encode()).decode()
|
|
60
|
+
payload, sig = decoded.rsplit("|", 1)
|
|
61
|
+
expected = hmac.new(
|
|
62
|
+
self._secret, payload.encode(), hashlib.sha256
|
|
63
|
+
).hexdigest()[:16]
|
|
64
|
+
if len(sig) != len(expected) or not hmac.compare_digest(sig, expected):
|
|
65
|
+
raise ValueError("checksum mismatch")
|
|
66
|
+
data = json.loads(payload)
|
|
67
|
+
resource = str(data["r"])
|
|
68
|
+
offset = int(data["o"])
|
|
69
|
+
revision = int(data["v"])
|
|
70
|
+
except (
|
|
71
|
+
ValueError,
|
|
72
|
+
KeyError,
|
|
73
|
+
TypeError,
|
|
74
|
+
json.JSONDecodeError,
|
|
75
|
+
UnicodeDecodeError,
|
|
76
|
+
) as exc:
|
|
77
|
+
raise DatabricksError(
|
|
78
|
+
400,
|
|
79
|
+
"INVALID_PARAMETER_VALUE",
|
|
80
|
+
"Invalid page token",
|
|
81
|
+
) from exc
|
|
82
|
+
if resource != expected_resource or offset < 0:
|
|
83
|
+
raise DatabricksError(
|
|
84
|
+
400,
|
|
85
|
+
"INVALID_PARAMETER_VALUE",
|
|
86
|
+
"Invalid page token",
|
|
87
|
+
)
|
|
88
|
+
return PageCursor(resource=resource, offset=offset, revision=revision)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared models."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Pagination helpers using PageTokenCodec."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
from semblance_databricks.errors import DatabricksError
|
|
9
|
+
from semblance_databricks.ids import PageTokenCodec
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def paginate(
|
|
15
|
+
items: Sequence[T],
|
|
16
|
+
*,
|
|
17
|
+
page_size: int | None,
|
|
18
|
+
page_token: str | None,
|
|
19
|
+
resource: str,
|
|
20
|
+
codec: PageTokenCodec,
|
|
21
|
+
revision: int,
|
|
22
|
+
default_page_size: int = 20,
|
|
23
|
+
max_page_size: int = 100,
|
|
24
|
+
) -> tuple[list[T], str | None]:
|
|
25
|
+
size = default_page_size if page_size is None else page_size
|
|
26
|
+
if size <= 0:
|
|
27
|
+
raise DatabricksError(
|
|
28
|
+
400,
|
|
29
|
+
"INVALID_PARAMETER_VALUE",
|
|
30
|
+
"page_size must be positive",
|
|
31
|
+
)
|
|
32
|
+
size = min(size, max_page_size)
|
|
33
|
+
offset = 0
|
|
34
|
+
if page_token:
|
|
35
|
+
cursor = codec.decode(page_token, resource)
|
|
36
|
+
if cursor.revision != revision:
|
|
37
|
+
raise DatabricksError(
|
|
38
|
+
400,
|
|
39
|
+
"INVALID_PARAMETER_VALUE",
|
|
40
|
+
"Invalid page token",
|
|
41
|
+
)
|
|
42
|
+
offset = cursor.offset
|
|
43
|
+
page = list(items[offset : offset + size])
|
|
44
|
+
next_offset = offset + len(page)
|
|
45
|
+
next_token = None
|
|
46
|
+
if next_offset < len(items):
|
|
47
|
+
next_token = codec.encode(resource, next_offset, revision)
|
|
48
|
+
return page, next_token
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Named Databricks operations for CLI and compatibility lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from semblance_databricks.compatibility import operations_table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DatabricksOperation:
|
|
12
|
+
operation_id: str
|
|
13
|
+
method: str
|
|
14
|
+
path: str
|
|
15
|
+
support_level: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def registered_operations() -> list[DatabricksOperation]:
|
|
19
|
+
ops: list[DatabricksOperation] = []
|
|
20
|
+
for row in operations_table():
|
|
21
|
+
ops.append(
|
|
22
|
+
DatabricksOperation(
|
|
23
|
+
operation_id=str(row.get("operationId", "")),
|
|
24
|
+
method=str(row.get("method", "")),
|
|
25
|
+
path=str(row.get("path", "")),
|
|
26
|
+
support_level=str(row.get("supportLevel", "")),
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
return ops
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Databricks service routers."""
|