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 ADDED
@@ -0,0 +1,3 @@
1
+ # DO NOT ADD ANYTHING ELSE TO THIS FILE FOR COMPATIBILITY WITH OTHER databricks.* PACKAGES
2
+ # SEE https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,3 @@
1
+ # DO NOT ADD ANYTHING ELSE TO THIS FILE FOR COMPATIBILITY WITH OTHER databricks.labs.* PACKAGES
2
+ # SEE https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -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