databricks-labs-blueprint 0.0.2__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.
- databricks/__init__.py +3 -0
- databricks/labs/__init__.py +3 -0
- databricks/labs/blueprint/__about__.py +1 -0
- databricks/labs/blueprint/__init__.py +0 -0
- databricks/labs/blueprint/__main__.py +122 -0
- databricks/labs/blueprint/cli.py +79 -0
- databricks/labs/blueprint/commands.py +227 -0
- databricks/labs/blueprint/entrypoint.py +88 -0
- databricks/labs/blueprint/installer.py +88 -0
- databricks/labs/blueprint/limiter.py +67 -0
- databricks/labs/blueprint/logger.py +69 -0
- databricks/labs/blueprint/parallel.py +139 -0
- databricks/labs/blueprint/py.typed +1 -0
- databricks/labs/blueprint/tui.py +150 -0
- databricks/labs/blueprint/wheels.py +223 -0
- databricks_labs_blueprint-0.0.2.dist-info/METADATA +563 -0
- databricks_labs_blueprint-0.0.2.dist-info/RECORD +20 -0
- databricks_labs_blueprint-0.0.2.dist-info/WHEEL +4 -0
- databricks_labs_blueprint-0.0.2.dist-info/licenses/LICENSE +69 -0
- databricks_labs_blueprint-0.0.2.dist-info/licenses/NOTICE +9 -0
databricks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.2"
|
|
File without changes
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from databricks.labs.blueprint.cli import App
|
|
4
|
+
from databricks.labs.blueprint.entrypoint import (
|
|
5
|
+
find_project_root,
|
|
6
|
+
get_logger,
|
|
7
|
+
relative_paths,
|
|
8
|
+
)
|
|
9
|
+
from databricks.labs.blueprint.tui import Prompts
|
|
10
|
+
|
|
11
|
+
blueprint = App(__file__)
|
|
12
|
+
logger = get_logger(__file__)
|
|
13
|
+
|
|
14
|
+
main_py_file = '''from databricks.sdk import AccountClient, WorkspaceClient
|
|
15
|
+
from databricks.labs.blueprint.entrypoint import get_logger
|
|
16
|
+
from databricks.labs.blueprint.cli import App
|
|
17
|
+
|
|
18
|
+
__app__ = App(__file__)
|
|
19
|
+
logger = get_logger(__file__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@__app__.command
|
|
23
|
+
def me(w: WorkspaceClient, greeting: str):
|
|
24
|
+
"""Shows current username"""
|
|
25
|
+
logger.info(f"{greeting}, {w.current_user.me().user_name}!")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@__app__.command(is_account=True)
|
|
29
|
+
def workspaces(a: AccountClient):
|
|
30
|
+
"""Shows workspaces"""
|
|
31
|
+
for ws in a.workspaces.list():
|
|
32
|
+
logger.info(f"Workspace: {ws.workspace_name} ({ws.workspace_id})")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if "__main__" == __name__:
|
|
36
|
+
__app__()
|
|
37
|
+
|
|
38
|
+
'''
|
|
39
|
+
|
|
40
|
+
labs_yml_file = """---
|
|
41
|
+
name: __app__
|
|
42
|
+
description: Common libraries for Databricks Labs
|
|
43
|
+
install:
|
|
44
|
+
script: src/databricks/labs/__app__/__init__.py
|
|
45
|
+
entrypoint: src/databricks/labs/__app__/__main__.py
|
|
46
|
+
min_python: 3.10
|
|
47
|
+
- name: me
|
|
48
|
+
description: shows current username
|
|
49
|
+
flags:
|
|
50
|
+
- name: greeting
|
|
51
|
+
default: Hello
|
|
52
|
+
description: Greeting prefix
|
|
53
|
+
- name: workspaces
|
|
54
|
+
is_account: true
|
|
55
|
+
description: shows current workspaces
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@blueprint.command(is_unauthenticated=True)
|
|
60
|
+
def init_project(target):
|
|
61
|
+
"""Creates the required boilerplate structure"""
|
|
62
|
+
prompts = Prompts()
|
|
63
|
+
|
|
64
|
+
project_root = find_project_root()
|
|
65
|
+
target_folder = Path(target)
|
|
66
|
+
|
|
67
|
+
project_name = prompts.question("Name of the project", default=target_folder.name)
|
|
68
|
+
src_dir, dst_dir = relative_paths(project_root, target_folder.absolute())
|
|
69
|
+
|
|
70
|
+
ignore_names = {
|
|
71
|
+
".git",
|
|
72
|
+
".venv",
|
|
73
|
+
".databricks",
|
|
74
|
+
".mypy_cache",
|
|
75
|
+
".idea",
|
|
76
|
+
".coverage",
|
|
77
|
+
"htmlcov",
|
|
78
|
+
"__pycache__",
|
|
79
|
+
"tests",
|
|
80
|
+
".databricks-login.json",
|
|
81
|
+
"coverage.xml",
|
|
82
|
+
"dist",
|
|
83
|
+
}
|
|
84
|
+
queue: list[Path] = [src_dir] # type: ignore[annotation-unchecked]
|
|
85
|
+
while queue:
|
|
86
|
+
current = queue.pop(0)
|
|
87
|
+
if current.name in ignore_names:
|
|
88
|
+
continue
|
|
89
|
+
if current.is_file():
|
|
90
|
+
relative_file_name = current.as_posix().replace("blueprint", project_name)
|
|
91
|
+
dst_file = dst_dir / relative_file_name
|
|
92
|
+
dst_file.parent.mkdir(exist_ok=True, parents=True)
|
|
93
|
+
with current.open("r") as r, dst_file.open("w") as w:
|
|
94
|
+
content = r.read().replace("blueprint", project_name)
|
|
95
|
+
content = content.replace("databricks-sdk", "databricks-labs-blueprint")
|
|
96
|
+
w.write(content)
|
|
97
|
+
continue
|
|
98
|
+
virtual_env_marker = current / "pyvenv.cfg"
|
|
99
|
+
if virtual_env_marker.exists():
|
|
100
|
+
continue
|
|
101
|
+
for file in current.iterdir():
|
|
102
|
+
if file.as_posix() == "src/databricks/labs/blueprint":
|
|
103
|
+
continue
|
|
104
|
+
queue.append(file)
|
|
105
|
+
inner_package_dir = dst_dir / "src" / "databricks" / "labs" / project_name
|
|
106
|
+
inner_package_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
with (inner_package_dir / "__main__.py").open("w") as f:
|
|
108
|
+
f.write(main_py_file.replace("__app__", project_name))
|
|
109
|
+
with (inner_package_dir / "__init__.py").open("w") as f:
|
|
110
|
+
f.write(f"from databricks.labs.{project_name}.__about__ import __version__")
|
|
111
|
+
with (inner_package_dir / "__about__.py").open("w") as f:
|
|
112
|
+
f.write('__version__ = "0.0.0"\n')
|
|
113
|
+
with (dst_dir / "labs.yml").open("w") as f:
|
|
114
|
+
f.write(labs_yml_file.replace("__app__", project_name))
|
|
115
|
+
with (dst_dir / "CODEOWNERS").open("w") as f:
|
|
116
|
+
f.write(f"* @nfx\n/src @databrickslabs/{project_name}-write\n/tests @databrickslabs/{project_name}-write\n")
|
|
117
|
+
with (dst_dir / "CHANGELOG.md").open("w") as f:
|
|
118
|
+
f.write(f"# Version changelog\n\n## 0.0.0\n\nInitial {project_name} commit\n")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if "__main__" == __name__:
|
|
122
|
+
blueprint()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from databricks.sdk import AccountClient, WorkspaceClient
|
|
7
|
+
|
|
8
|
+
from databricks.labs.blueprint.entrypoint import get_logger, run_main
|
|
9
|
+
from databricks.labs.blueprint.wheels import ProductInfo
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Command:
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
fn: Callable[..., None]
|
|
17
|
+
is_account: bool = False
|
|
18
|
+
is_unauthenticated: bool = False
|
|
19
|
+
|
|
20
|
+
def needs_workspace_client(self):
|
|
21
|
+
if self.is_unauthenticated:
|
|
22
|
+
return False
|
|
23
|
+
if self.is_account:
|
|
24
|
+
return False
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class App:
|
|
29
|
+
def __init__(self, __file: str):
|
|
30
|
+
self._mapping: dict[str, Command] = {}
|
|
31
|
+
self._logger = get_logger(__file)
|
|
32
|
+
self._product_info = ProductInfo()
|
|
33
|
+
|
|
34
|
+
def command(self, is_account: bool = False, is_unauthenticated: bool = False):
|
|
35
|
+
def decorator(func):
|
|
36
|
+
command_name = func.__name__.replace("_", "-")
|
|
37
|
+
if not func.__doc__:
|
|
38
|
+
raise SyntaxError(f"{func.__name__} must have some doc comment")
|
|
39
|
+
self._mapping[command_name] = Command(
|
|
40
|
+
name=command_name,
|
|
41
|
+
description=func.__doc__,
|
|
42
|
+
fn=func,
|
|
43
|
+
is_account=is_account,
|
|
44
|
+
is_unauthenticated=is_unauthenticated,
|
|
45
|
+
)
|
|
46
|
+
return func
|
|
47
|
+
|
|
48
|
+
return decorator
|
|
49
|
+
|
|
50
|
+
def _route(self, raw):
|
|
51
|
+
payload = json.loads(raw)
|
|
52
|
+
command = payload["command"]
|
|
53
|
+
if command not in self._mapping:
|
|
54
|
+
msg = f"cannot find command: {command}"
|
|
55
|
+
raise KeyError(msg)
|
|
56
|
+
flags = payload["flags"]
|
|
57
|
+
log_level = flags.pop("log_level")
|
|
58
|
+
if log_level == "disabled":
|
|
59
|
+
log_level = "info"
|
|
60
|
+
databricks_logger = logging.getLogger("databricks")
|
|
61
|
+
databricks_logger.setLevel(log_level.upper())
|
|
62
|
+
kwargs = {k.replace("-", "_"): v for k, v in flags.items()}
|
|
63
|
+
try:
|
|
64
|
+
product_name = self._product_info.product_name()
|
|
65
|
+
product_version = self._product_info.version()
|
|
66
|
+
if self._mapping[command].needs_workspace_client():
|
|
67
|
+
kwargs["w"] = WorkspaceClient(product=product_name, product_version=product_version)
|
|
68
|
+
elif self._mapping[command].is_account:
|
|
69
|
+
kwargs["a"] = AccountClient(product=product_name, product_version=product_version)
|
|
70
|
+
self._mapping[command].fn(**kwargs)
|
|
71
|
+
except Exception as err:
|
|
72
|
+
logger = self._logger.getChild(command)
|
|
73
|
+
if log_level.lower() in ("debug", "trace"):
|
|
74
|
+
logger.error(f"Failed to call {command}", exc_info=err)
|
|
75
|
+
else:
|
|
76
|
+
logger.error(f"{err.__class__.__name__}: {err}")
|
|
77
|
+
|
|
78
|
+
def __call__(self):
|
|
79
|
+
run_main(self._route)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import html
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from databricks.sdk.core import DatabricksError
|
|
12
|
+
from databricks.sdk.mixins.compute import ClustersExt
|
|
13
|
+
from databricks.sdk.service import compute
|
|
14
|
+
|
|
15
|
+
_out_re = re.compile(r"Out\[[\d\s]+]:\s")
|
|
16
|
+
_tag_re = re.compile(r"<[^>]*>")
|
|
17
|
+
_exception_re = re.compile(r".*Exception:\s+(.*)")
|
|
18
|
+
_execution_error_re = re.compile(r"ExecutionError: ([\s\S]*)\n(StatusCode=[0-9]*)\n(StatusDescription=.*)\n")
|
|
19
|
+
_error_message_re = re.compile(r"ErrorMessage=(.+)\n")
|
|
20
|
+
_ascii_escape_re = re.compile(r"(\x9B|\x1B\[)[0-?]*[ -/]*[@-~]")
|
|
21
|
+
_LOG = logging.getLogger("databricks.sdk")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _ReturnToPrintJson(ast.NodeTransformer):
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._has_json_import = False
|
|
27
|
+
self._has_print = False
|
|
28
|
+
self.has_return = False
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def transform(code: str) -> tuple[str, bool]:
|
|
32
|
+
major, minor, _ = platform.python_version_tuple()
|
|
33
|
+
unsupported_version = (int(major), int(minor)) < (3, 8)
|
|
34
|
+
has_return_in_last_line = code.splitlines()[-1].startswith("return ")
|
|
35
|
+
|
|
36
|
+
if unsupported_version and not has_return_in_last_line:
|
|
37
|
+
return code, False
|
|
38
|
+
|
|
39
|
+
if unsupported_version and has_return_in_last_line:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"dynamic conversion of return .. to print(json.dumps(..)) " "is only possible starting from Python 3.8"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# perform AST transformations for very repetitive tasks, like JSON serialization
|
|
45
|
+
code_tree = ast.parse(code)
|
|
46
|
+
transform = _ReturnToPrintJson()
|
|
47
|
+
new_tree = transform.apply(code_tree)
|
|
48
|
+
code = ast.unparse(new_tree)
|
|
49
|
+
return code, transform.has_return
|
|
50
|
+
|
|
51
|
+
def apply(self, raw_node: ast.AST) -> ast.AST:
|
|
52
|
+
node: ast.stmt = self.visit(raw_node)
|
|
53
|
+
if self.has_return and self._has_print:
|
|
54
|
+
msg = "cannot have print() call, return .. is converted to print(json.dumps(..))"
|
|
55
|
+
raise ValueError(msg)
|
|
56
|
+
if self.has_return and not self._has_json_import:
|
|
57
|
+
new_import: ast.stmt = ast.parse("import json").body[0]
|
|
58
|
+
node.body.insert(0, new_import) # type: ignore[attr-defined]
|
|
59
|
+
return node
|
|
60
|
+
|
|
61
|
+
def visit_Import(self, node: ast.Import) -> ast.Import: # noqa: N802
|
|
62
|
+
for name in node.names:
|
|
63
|
+
if "json" != ast.unparse(name):
|
|
64
|
+
continue
|
|
65
|
+
self._has_json_import = True
|
|
66
|
+
break
|
|
67
|
+
return node
|
|
68
|
+
|
|
69
|
+
def visit_Call(self, node: ast.Call): # noqa: N802
|
|
70
|
+
if ast.unparse(node.func) == "print":
|
|
71
|
+
self._has_print = True
|
|
72
|
+
return node
|
|
73
|
+
|
|
74
|
+
def visit_Return(self, node): # noqa: N802
|
|
75
|
+
value = node.value
|
|
76
|
+
if not value:
|
|
77
|
+
# Remove the original return statement
|
|
78
|
+
return None
|
|
79
|
+
return_expr = ast.unparse(value)
|
|
80
|
+
replaced_code = f"print(json.dumps({return_expr}))"
|
|
81
|
+
print_call = ast.parse(replaced_code).body[0]
|
|
82
|
+
self.has_return = True
|
|
83
|
+
return print_call
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CommandExecutor:
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
clusters: ClustersExt,
|
|
90
|
+
command_execution: compute.CommandExecutionAPI,
|
|
91
|
+
cluster_id_provider: Callable[[], str],
|
|
92
|
+
*,
|
|
93
|
+
language: compute.Language = compute.Language.PYTHON,
|
|
94
|
+
):
|
|
95
|
+
self._cluster_id_provider = cluster_id_provider
|
|
96
|
+
self._language = language
|
|
97
|
+
self._clusters = clusters
|
|
98
|
+
self._commands = command_execution
|
|
99
|
+
self._lock = threading.Lock()
|
|
100
|
+
self._ctx: compute.ContextStatusResponse | None = None
|
|
101
|
+
|
|
102
|
+
def run(self, code: str, *, result_as_json=False, detect_return=True) -> Any:
|
|
103
|
+
code = self._trim_leading_whitespace(code)
|
|
104
|
+
|
|
105
|
+
if self._language == compute.Language.PYTHON and detect_return and not result_as_json:
|
|
106
|
+
code, result_as_json = _ReturnToPrintJson.transform(code)
|
|
107
|
+
|
|
108
|
+
ctx = self._running_command_context()
|
|
109
|
+
cluster_id = self._cluster_id_provider()
|
|
110
|
+
command_status_response = self._commands.execute(
|
|
111
|
+
cluster_id=cluster_id, language=self._language, context_id=ctx.id, command=code
|
|
112
|
+
).result()
|
|
113
|
+
|
|
114
|
+
results = command_status_response.results
|
|
115
|
+
assert results is not None
|
|
116
|
+
if command_status_response.status == compute.CommandStatus.FINISHED:
|
|
117
|
+
self._raise_if_failed(results)
|
|
118
|
+
if results.result_type == compute.ResultType.TEXT and result_as_json:
|
|
119
|
+
try:
|
|
120
|
+
# parse json from converted return statement
|
|
121
|
+
assert results.data is not None
|
|
122
|
+
return json.loads(results.data)
|
|
123
|
+
except json.JSONDecodeError as e:
|
|
124
|
+
_LOG.warning("cannot parse converted return statement. Just returning text", exc_info=e)
|
|
125
|
+
return results.data
|
|
126
|
+
return results.data
|
|
127
|
+
else:
|
|
128
|
+
# there might be an opportunity to convert builtin exceptions
|
|
129
|
+
assert results.summary is not None
|
|
130
|
+
raise DatabricksError(results.summary)
|
|
131
|
+
|
|
132
|
+
def install_notebook_library(self, library: str):
|
|
133
|
+
return self.run(
|
|
134
|
+
f"""
|
|
135
|
+
get_ipython().run_line_magic('pip', 'install {library}')
|
|
136
|
+
dbutils.library.restartPython()
|
|
137
|
+
"""
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def _running_command_context(self) -> compute.ContextStatusResponse:
|
|
141
|
+
if self._ctx:
|
|
142
|
+
return self._ctx
|
|
143
|
+
with self._lock:
|
|
144
|
+
if self._ctx:
|
|
145
|
+
return self._ctx
|
|
146
|
+
cluster_id = self._cluster_id_provider()
|
|
147
|
+
self._clusters.ensure_cluster_is_running(cluster_id)
|
|
148
|
+
self._ctx = self._commands.create(cluster_id=cluster_id, language=self._language).result()
|
|
149
|
+
return self._ctx
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _is_failed(results: compute.Results) -> bool:
|
|
153
|
+
return results.result_type == compute.ResultType.ERROR
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _text(results: compute.Results) -> str:
|
|
157
|
+
if results.result_type != compute.ResultType.TEXT:
|
|
158
|
+
return ""
|
|
159
|
+
return _out_re.sub("", str(results.data))
|
|
160
|
+
|
|
161
|
+
def _raise_if_failed(self, results: compute.Results):
|
|
162
|
+
if not self._is_failed(results):
|
|
163
|
+
return
|
|
164
|
+
raise DatabricksError(self._error_from_results(results))
|
|
165
|
+
|
|
166
|
+
def _error_from_results(self, results: compute.Results):
|
|
167
|
+
"""Converts results into an error
|
|
168
|
+
|
|
169
|
+
:param results: compute.Results:
|
|
170
|
+
|
|
171
|
+
"""
|
|
172
|
+
if not self._is_failed(results):
|
|
173
|
+
return
|
|
174
|
+
results_cause = results.cause
|
|
175
|
+
if results_cause:
|
|
176
|
+
sys.stderr.write(_ascii_escape_re.sub("", results_cause))
|
|
177
|
+
else:
|
|
178
|
+
results_cause = ""
|
|
179
|
+
|
|
180
|
+
summary = ""
|
|
181
|
+
if results.summary:
|
|
182
|
+
summary = results.summary
|
|
183
|
+
summary = _tag_re.sub("", summary)
|
|
184
|
+
summary = html.unescape(summary)
|
|
185
|
+
|
|
186
|
+
exception_matches = _exception_re.findall(summary)
|
|
187
|
+
if len(exception_matches) == 1:
|
|
188
|
+
summary = exception_matches[0].replace("; nested exception is:", "")
|
|
189
|
+
summary = summary.rstrip(" ")
|
|
190
|
+
return summary
|
|
191
|
+
|
|
192
|
+
execution_error_matches = _execution_error_re.findall(results_cause)
|
|
193
|
+
if len(execution_error_matches) == 1:
|
|
194
|
+
return "\n".join(execution_error_matches[0])
|
|
195
|
+
|
|
196
|
+
error_message_matches = _error_message_re.findall(results_cause)
|
|
197
|
+
if len(error_message_matches) == 1:
|
|
198
|
+
return error_message_matches[0]
|
|
199
|
+
|
|
200
|
+
return summary
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _trim_leading_whitespace(command_str: str) -> str:
|
|
204
|
+
"""Removes leading whitespace, so that Python code blocks that
|
|
205
|
+
are embedded into Python code still could be interpreted properly."""
|
|
206
|
+
lines = command_str.replace("\t", " ").split("\n")
|
|
207
|
+
leading_whitespace = sys.maxsize
|
|
208
|
+
if lines[0] == "":
|
|
209
|
+
lines = lines[1:]
|
|
210
|
+
for line in lines:
|
|
211
|
+
pos = 0
|
|
212
|
+
for char in line:
|
|
213
|
+
if char in (" ", "\t"):
|
|
214
|
+
pos += 1
|
|
215
|
+
else:
|
|
216
|
+
break
|
|
217
|
+
if pos < leading_whitespace:
|
|
218
|
+
leading_whitespace = pos
|
|
219
|
+
new_command = ""
|
|
220
|
+
for line in lines:
|
|
221
|
+
if line == "" or line.strip(" \t") == "":
|
|
222
|
+
continue
|
|
223
|
+
if len(line) < leading_whitespace:
|
|
224
|
+
new_command += line + "\n"
|
|
225
|
+
else:
|
|
226
|
+
new_command += line[leading_whitespace:] + "\n"
|
|
227
|
+
return new_command.strip()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from databricks.labs.blueprint.logger import install_logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_logger(file_name: str):
|
|
10
|
+
"""Used as `get_logger(__file__)` to return a relevant logger for a file
|
|
11
|
+
|
|
12
|
+
:param file_name: str: use __file__ special constant
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
project_root = find_project_root().absolute()
|
|
16
|
+
entrypoint = Path(file_name).absolute()
|
|
17
|
+
|
|
18
|
+
relative = entrypoint.relative_to(project_root).as_posix()
|
|
19
|
+
relative = relative.removeprefix("src" + os.sep)
|
|
20
|
+
relative = relative.removesuffix("/__main__.py")
|
|
21
|
+
relative = relative.removesuffix("/__init__.py")
|
|
22
|
+
relative = relative.removesuffix("/cli.py")
|
|
23
|
+
relative = relative.removesuffix(".py")
|
|
24
|
+
module_name = relative.replace(os.sep, ".")
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(module_name)
|
|
27
|
+
|
|
28
|
+
level = "INFO"
|
|
29
|
+
if is_in_debug():
|
|
30
|
+
level = "DEBUG"
|
|
31
|
+
logger.setLevel(level)
|
|
32
|
+
|
|
33
|
+
return logger
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_main(main):
|
|
37
|
+
"""Runs main function with a logger
|
|
38
|
+
|
|
39
|
+
:param main: function that takes command-line arguments
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
install_logger()
|
|
43
|
+
main(*sys.argv[1:])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_project_root() -> Path:
|
|
47
|
+
"""Returns pathlib.Path for the nearest folder with pyproject.toml or setup.py file"""
|
|
48
|
+
this_path = Path(__file__) # TODO: check if this works in downstreams
|
|
49
|
+
# TODO: detect when in wheel
|
|
50
|
+
for leaf in ["pyproject.toml", "setup.py"]:
|
|
51
|
+
root = find_dir_with_leaf(this_path, leaf)
|
|
52
|
+
if root is not None:
|
|
53
|
+
return root
|
|
54
|
+
msg = "Cannot find project root"
|
|
55
|
+
raise NotADirectoryError(msg)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_dir_with_leaf(folder: Path, leaf: str) -> Path | None:
|
|
59
|
+
"""Returns path object for the nearest folder with a leaf file
|
|
60
|
+
|
|
61
|
+
:param folder: Path: starting location
|
|
62
|
+
:param leaf: str: name of the file or folder
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
root = folder.root
|
|
66
|
+
while str(folder.absolute()) != root:
|
|
67
|
+
if (folder / leaf).exists():
|
|
68
|
+
return folder
|
|
69
|
+
folder = folder.parent
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_in_debug() -> bool:
|
|
74
|
+
"""Returns true if run from VSCode or IntelliJ"""
|
|
75
|
+
if "IDE_PROJECT_ROOTS" in os.environ:
|
|
76
|
+
return True
|
|
77
|
+
return os.path.basename(sys.argv[0]) in ["_jb_pytest_runner.py", "testlauncher.py"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def relative_paths(*maybe_paths) -> list[Path]:
|
|
81
|
+
"""Converts list of paths to relative path objects
|
|
82
|
+
|
|
83
|
+
:param *maybe_paths: string-like arguments
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
all_paths = [Path(str(_)) for _ in maybe_paths]
|
|
87
|
+
common_path = Path(os.path.commonpath([_.as_posix() for _ in all_paths]))
|
|
88
|
+
return [_.relative_to(common_path) for _ in all_paths]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from json import JSONDecodeError
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
from databricks.sdk import WorkspaceClient
|
|
8
|
+
from databricks.sdk.errors import NotFound
|
|
9
|
+
from databricks.sdk.service.workspace import ImportFormat
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
Resources = dict[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RawState(TypedDict):
|
|
17
|
+
resources: dict[str, Resources]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IllegalState(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InstallState:
|
|
25
|
+
"""Manages ~/.{product}/state.json file on WorkspaceFS to track installations"""
|
|
26
|
+
|
|
27
|
+
_state: RawState | None = None
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self, ws: WorkspaceClient, product: str, config_version: int = 1, *, install_folder: str | None = None
|
|
31
|
+
):
|
|
32
|
+
self._ws = ws
|
|
33
|
+
self._product = product
|
|
34
|
+
self._install_folder = install_folder
|
|
35
|
+
self._config_version = config_version
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
def product(self) -> str:
|
|
39
|
+
return self._product
|
|
40
|
+
|
|
41
|
+
def install_folder(self) -> str:
|
|
42
|
+
if self._install_folder:
|
|
43
|
+
return self._install_folder
|
|
44
|
+
me = self._ws.current_user.me()
|
|
45
|
+
self._install_folder = f"/Users/{me.user_name}/.{self._product}"
|
|
46
|
+
return self._install_folder
|
|
47
|
+
|
|
48
|
+
def __getattr__(self, item: str) -> Resources:
|
|
49
|
+
with self._lock:
|
|
50
|
+
if not self._state:
|
|
51
|
+
self._state = self._load()
|
|
52
|
+
if item not in self._state["resources"]:
|
|
53
|
+
self._state["resources"][item] = {}
|
|
54
|
+
return self._state["resources"][item]
|
|
55
|
+
|
|
56
|
+
def _state_file(self) -> str:
|
|
57
|
+
return f"{self.install_folder()}/state.json"
|
|
58
|
+
|
|
59
|
+
def _load(self) -> RawState:
|
|
60
|
+
"""Loads remote state"""
|
|
61
|
+
default_state: RawState = {"resources": {}}
|
|
62
|
+
try:
|
|
63
|
+
raw = json.load(self._ws.workspace.download(self._state_file()))
|
|
64
|
+
version = raw.pop("$version", None)
|
|
65
|
+
if version != self._config_version:
|
|
66
|
+
msg = f"expected state $version={self._config_version}, got={version}"
|
|
67
|
+
raise IllegalState(msg)
|
|
68
|
+
return raw
|
|
69
|
+
except NotFound:
|
|
70
|
+
return default_state
|
|
71
|
+
except JSONDecodeError:
|
|
72
|
+
logger.warning(f"JSON state file corrupt: {self._state_file}")
|
|
73
|
+
return default_state
|
|
74
|
+
|
|
75
|
+
def save(self) -> None:
|
|
76
|
+
"""Saves remote state"""
|
|
77
|
+
with self._lock:
|
|
78
|
+
state: dict = {}
|
|
79
|
+
if self._state:
|
|
80
|
+
state = self._state.copy() # type: ignore[assignment]
|
|
81
|
+
state["$version"] = self._config_version
|
|
82
|
+
state_dump = json.dumps(state, indent=2).encode("utf8")
|
|
83
|
+
self._ws.workspace.upload(
|
|
84
|
+
self._state_file(),
|
|
85
|
+
state_dump, # type: ignore[arg-type]
|
|
86
|
+
format=ImportFormat.AUTO,
|
|
87
|
+
overwrite=True,
|
|
88
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RateLimiter:
|
|
10
|
+
"""Used to limit the rate of requests to a certain maximum rate within a specified burst period,
|
|
11
|
+
defaulting to one second. It is nice to control and manage the flow of requests to prevent
|
|
12
|
+
overloading a service.
|
|
13
|
+
|
|
14
|
+
It uses a variation of the Leaky Bucket algorithm (https://en.wikipedia.org/wiki/Leaky_bucket)
|
|
15
|
+
for its simplicity."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, max_requests: int = 30, burst_period_seconds: int = 1):
|
|
18
|
+
self._capacity = max_requests
|
|
19
|
+
self._burst_period_seconds = burst_period_seconds
|
|
20
|
+
self._lock = threading.RLock()
|
|
21
|
+
self._last = time.time()
|
|
22
|
+
self._bucket = 0
|
|
23
|
+
|
|
24
|
+
def throttle(self):
|
|
25
|
+
"""The throttle method is used to check and control the rate of requests. It's intended to be called
|
|
26
|
+
before making a new request and is thread-safe.
|
|
27
|
+
|
|
28
|
+
It calculates the time elapsed since the last request and subtracts this from the _burst_period_seconds
|
|
29
|
+
to determine how much time remains in the current burst period. If the calculated delay is less than zero,
|
|
30
|
+
it means that the burst period has elapsed, so it resets the request count and updates the last request
|
|
31
|
+
timestamp. If the request count exceeds the allowed maximum (_max_requests), it sleeps for the remaining
|
|
32
|
+
time in the burst period (delay) to enforce the rate limit."""
|
|
33
|
+
with self._lock:
|
|
34
|
+
now = time.time()
|
|
35
|
+
delay = self._burst_period_seconds - (now - self._last)
|
|
36
|
+
if delay < 0:
|
|
37
|
+
# If the bucket is empty, it stops leaking.
|
|
38
|
+
self._bucket = 0
|
|
39
|
+
self._last = now
|
|
40
|
+
self._bucket += 1
|
|
41
|
+
if self._bucket > self._capacity:
|
|
42
|
+
# If the bucket is over capacity, start leaking
|
|
43
|
+
# by sleeping on the current thread.
|
|
44
|
+
logger.debug(f"Throttled for {delay}s")
|
|
45
|
+
time.sleep(delay)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def rate_limited(*, max_requests: int = 30, burst_period_seconds: int = 1):
|
|
49
|
+
"""Limits execution frequency of decorated function
|
|
50
|
+
|
|
51
|
+
:param *: this function accepts keyword-only arguments
|
|
52
|
+
:param max_requests: int: (Default value = 30)
|
|
53
|
+
:param burst_period_seconds: int: (Default value = 1)
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def decorator(func):
|
|
58
|
+
rate_limiter = RateLimiter(max_requests=max_requests, burst_period_seconds=burst_period_seconds)
|
|
59
|
+
|
|
60
|
+
@wraps(func)
|
|
61
|
+
def wrapper(*args, **kwargs):
|
|
62
|
+
rate_limiter.throttle()
|
|
63
|
+
return func(*args, **kwargs)
|
|
64
|
+
|
|
65
|
+
return wrapper
|
|
66
|
+
|
|
67
|
+
return decorator
|