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,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
+ )
@@ -0,0 +1,5 @@
1
+ """Fixture package."""
2
+
3
+ from semblance_databricks.fixtures.loaders import bundled_acme_path, load_fixture_file
4
+
5
+ __all__ = ["bundled_acme_path", "load_fixture_file"]
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."""
@@ -0,0 +1,3 @@
1
+ from semblance_databricks.services.clusters.routes import create_clusters_router
2
+
3
+ __all__ = ["create_clusters_router"]