databricks-labs-pytester 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.
- databricks/__init__.py +3 -0
- databricks/labs/__init__.py +3 -0
- databricks/labs/pytester/__about__.py +1 -0
- databricks/labs/pytester/__init__.py +5 -0
- databricks/labs/pytester/fixtures/__init__.py +0 -0
- databricks/labs/pytester/fixtures/baseline.py +170 -0
- databricks/labs/pytester/fixtures/catalog.py +433 -0
- databricks/labs/pytester/fixtures/compute.py +316 -0
- databricks/labs/pytester/fixtures/connect.py +33 -0
- databricks/labs/pytester/fixtures/environment.py +148 -0
- databricks/labs/pytester/fixtures/iam.py +113 -0
- databricks/labs/pytester/fixtures/ml.py +147 -0
- databricks/labs/pytester/fixtures/notebooks.py +119 -0
- databricks/labs/pytester/fixtures/permissions.py +459 -0
- databricks/labs/pytester/fixtures/plugin.py +134 -0
- databricks/labs/pytester/fixtures/redash.py +59 -0
- databricks/labs/pytester/fixtures/secrets.py +71 -0
- databricks/labs/pytester/fixtures/sql.py +31 -0
- databricks/labs/pytester/fixtures/unwrap.py +107 -0
- databricks/labs/pytester/fixtures/watchdog.py +26 -0
- databricks/labs/pytester/py.typed +1 -0
- databricks_labs_pytester-0.1.0.dist-info/METADATA +1064 -0
- databricks_labs_pytester-0.1.0.dist-info/RECORD +29 -0
- databricks_labs_pytester-0.1.0.dist-info/WHEEL +4 -0
- databricks_labs_pytester-0.1.0.dist-info/entry_points.txt +2 -0
- databricks_labs_pytester-0.1.0.dist-info/licenses/LICENSE +69 -0
- databricks_labs_pytester-0.1.0.dist-info/licenses/NOTICE +19 -0
- tests/resources/hatchling-whl/src/hatchling_whl/__about__.py +1 -0
- tests/resources/hatchling-whl/src/hatchling_whl/__init__.py +6 -0
databricks/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import random
|
|
3
|
+
import string
|
|
4
|
+
|
|
5
|
+
from pytest import fixture
|
|
6
|
+
|
|
7
|
+
from databricks.sdk import WorkspaceClient
|
|
8
|
+
from databricks.sdk.errors import DatabricksError
|
|
9
|
+
|
|
10
|
+
_LOG = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@fixture
|
|
14
|
+
def make_random():
|
|
15
|
+
"""
|
|
16
|
+
Fixture to generate random strings.
|
|
17
|
+
|
|
18
|
+
This fixture provides a function to generate random strings of a specified length.
|
|
19
|
+
The generated strings are created using a character set consisting of uppercase letters,
|
|
20
|
+
lowercase letters, and digits.
|
|
21
|
+
|
|
22
|
+
To generate a random string with default length of 16 characters:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
random_string = make_random()
|
|
26
|
+
assert len(random_string) == 16
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To generate a random string with a specified length:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
random_string = make_random(k=8)
|
|
33
|
+
assert len(random_string) == 8
|
|
34
|
+
```
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def inner(k=16) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Generate a random string.
|
|
40
|
+
|
|
41
|
+
Parameters:
|
|
42
|
+
-----------
|
|
43
|
+
k : int, optional
|
|
44
|
+
Length of the random string (default is 16).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
--------
|
|
48
|
+
str:
|
|
49
|
+
A randomly generated string.
|
|
50
|
+
"""
|
|
51
|
+
charset = string.ascii_uppercase + string.ascii_lowercase + string.digits
|
|
52
|
+
return "".join(random.choices(charset, k=int(k)))
|
|
53
|
+
|
|
54
|
+
return inner
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def factory(name, create, remove):
|
|
58
|
+
"""
|
|
59
|
+
Factory function for creating fixtures.
|
|
60
|
+
|
|
61
|
+
This function creates a fixture for managing resources (e.g., secret scopes) within test functions.
|
|
62
|
+
The provided ``create`` function is used to create a resource, and the provided ``remove`` function
|
|
63
|
+
is used to remove the resource after the test is complete.
|
|
64
|
+
|
|
65
|
+
Parameters:
|
|
66
|
+
-----------
|
|
67
|
+
name : str
|
|
68
|
+
The name of the resource (e.g., "secret scope").
|
|
69
|
+
create : function
|
|
70
|
+
A function to create the resource.
|
|
71
|
+
remove : function
|
|
72
|
+
A function to remove the resource.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
--------
|
|
76
|
+
function:
|
|
77
|
+
A function to create and manage the resource fixture.
|
|
78
|
+
|
|
79
|
+
Usage Example:
|
|
80
|
+
--------------
|
|
81
|
+
To create a fixture for managing secret scopes:
|
|
82
|
+
|
|
83
|
+
.. code-block:: python
|
|
84
|
+
|
|
85
|
+
@pytest.fixture
|
|
86
|
+
def make_secret_scope(ws, make_random):
|
|
87
|
+
def create(**kwargs):
|
|
88
|
+
name = f"sdk-{make_random(4)}"
|
|
89
|
+
ws.secrets.create_scope(name, **kwargs)
|
|
90
|
+
return name
|
|
91
|
+
|
|
92
|
+
def cleanup(scope):
|
|
93
|
+
ws.secrets.delete_scope(scope)
|
|
94
|
+
|
|
95
|
+
yield from factory("secret scope", create, cleanup)
|
|
96
|
+
"""
|
|
97
|
+
cleanup = []
|
|
98
|
+
|
|
99
|
+
def inner(**kwargs):
|
|
100
|
+
some = create(**kwargs)
|
|
101
|
+
_LOG.debug(f"added {name} fixture: {some}")
|
|
102
|
+
cleanup.append(some)
|
|
103
|
+
return some
|
|
104
|
+
|
|
105
|
+
yield inner
|
|
106
|
+
_LOG.debug(f"clearing {len(cleanup)} {name} fixtures")
|
|
107
|
+
for some in cleanup:
|
|
108
|
+
try:
|
|
109
|
+
_LOG.debug(f"removing {name} fixture: {some}")
|
|
110
|
+
remove(some)
|
|
111
|
+
except DatabricksError as e:
|
|
112
|
+
_LOG.debug(f"ignoring error while {name} {some} teardown: {e}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@fixture
|
|
116
|
+
def product_info():
|
|
117
|
+
return None, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@fixture
|
|
121
|
+
def ws(debug_env: dict[str, str], product_info: tuple[str, str]) -> WorkspaceClient:
|
|
122
|
+
"""
|
|
123
|
+
Create and provide a Databricks WorkspaceClient object.
|
|
124
|
+
|
|
125
|
+
This fixture initializes a Databricks WorkspaceClient object, which can be used
|
|
126
|
+
to interact with the Databricks workspace API. The created instance of WorkspaceClient
|
|
127
|
+
is shared across all test functions within the test session.
|
|
128
|
+
|
|
129
|
+
See [detailed documentation](https://databricks-sdk-py.readthedocs.io/en/latest/authentication.html) for the list
|
|
130
|
+
of environment variables that can be used to authenticate the WorkspaceClient.
|
|
131
|
+
|
|
132
|
+
In your test functions, include this fixture as an argument to use the WorkspaceClient:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
def test_workspace_operations(ws):
|
|
136
|
+
clusters = ws.clusters.list_clusters()
|
|
137
|
+
assert len(clusters) >= 0
|
|
138
|
+
```
|
|
139
|
+
"""
|
|
140
|
+
product_name, product_version = product_info
|
|
141
|
+
# ignores fixed in https://github.com/databricks/databricks-sdk-py/pull/760
|
|
142
|
+
return WorkspaceClient(
|
|
143
|
+
host=debug_env["DATABRICKS_HOST"],
|
|
144
|
+
auth_type=debug_env.get("DATABRICKS_AUTH_TYPE"), # type: ignore
|
|
145
|
+
token=debug_env.get("DATABRICKS_TOKEN"), # type: ignore
|
|
146
|
+
username=debug_env.get("DATABRICKS_USERNAME"), # type: ignore
|
|
147
|
+
password=debug_env.get("DATABRICKS_PASSWORD"), # type: ignore
|
|
148
|
+
client_id=debug_env.get("DATABRICKS_CLIENT_ID"), # type: ignore
|
|
149
|
+
client_secret=debug_env.get("DATABRICKS_CLIENT_SECRET"), # type: ignore
|
|
150
|
+
debug_truncate_bytes=debug_env.get("DATABRICKS_DEBUG_TRUNCATE_BYTES"), # type: ignore
|
|
151
|
+
debug_headers=debug_env.get("DATABRICKS_DEBUG_HEADERS"), # type: ignore
|
|
152
|
+
azure_client_id=debug_env.get("ARM_CLIENT_ID"), # type: ignore
|
|
153
|
+
azure_tenant_id=debug_env.get("ARM_TENANT_ID"), # type: ignore
|
|
154
|
+
azure_client_secret=debug_env.get("ARM_CLIENT_SECRET"), # type: ignore
|
|
155
|
+
cluster_id=debug_env.get("DATABRICKS_CLUSTER_ID"), # type: ignore
|
|
156
|
+
product=product_name,
|
|
157
|
+
product_version=product_version,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@fixture
|
|
162
|
+
def log_workspace_link(ws):
|
|
163
|
+
"""Returns a function to log a workspace link."""
|
|
164
|
+
|
|
165
|
+
def inner(name: str, path: str, *, anchor: bool = True):
|
|
166
|
+
a = '#' if anchor else ''
|
|
167
|
+
url = f'https://{ws.config.hostname}/{a}{path}'
|
|
168
|
+
_LOG.info(f'Created {name}: {url}')
|
|
169
|
+
|
|
170
|
+
return inner
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Generator, Callable
|
|
3
|
+
from pytest import fixture
|
|
4
|
+
from databricks.labs.blueprint.commands import CommandExecutor
|
|
5
|
+
from databricks.sdk.errors import DatabricksError
|
|
6
|
+
from databricks.sdk.service.catalog import (
|
|
7
|
+
FunctionInfo,
|
|
8
|
+
SchemaInfo,
|
|
9
|
+
TableInfo,
|
|
10
|
+
TableType,
|
|
11
|
+
DataSourceFormat,
|
|
12
|
+
CatalogInfo,
|
|
13
|
+
StorageCredentialInfo,
|
|
14
|
+
AwsIamRoleRequest,
|
|
15
|
+
AzureServicePrincipal,
|
|
16
|
+
)
|
|
17
|
+
from databricks.sdk.service.compute import Language
|
|
18
|
+
from databricks.labs.pytester.fixtures.baseline import factory
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def escape_sql_identifier(path: str, *, maxsplit: int = 2) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Escapes the path components to make them SQL safe.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
path (str): The dot-separated path of a catalog object.
|
|
29
|
+
maxsplit (int): The maximum number of splits to perform.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
str: The path with all parts escaped in backticks.
|
|
33
|
+
"""
|
|
34
|
+
if not path:
|
|
35
|
+
return path
|
|
36
|
+
parts = path.split(".", maxsplit=maxsplit)
|
|
37
|
+
escaped = [f"`{part.strip('`').replace('`', '``')}`" for part in parts]
|
|
38
|
+
return ".".join(escaped)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@fixture
|
|
42
|
+
# pylint: disable-next=too-many-statements,too-complex
|
|
43
|
+
def make_table(
|
|
44
|
+
sql_backend,
|
|
45
|
+
make_schema,
|
|
46
|
+
make_random,
|
|
47
|
+
log_workspace_link,
|
|
48
|
+
watchdog_remove_after,
|
|
49
|
+
) -> Generator[Callable[..., TableInfo], None, None]:
|
|
50
|
+
"""
|
|
51
|
+
Create a table and return its info. Remove it after the test. Returns instance of `databricks.sdk.service.catalog.TableInfo`.
|
|
52
|
+
|
|
53
|
+
Keyword Arguments:
|
|
54
|
+
* `catalog_name` (str): The name of the catalog where the table will be created. Default is `hive_metastore`.
|
|
55
|
+
* `schema_name` (str): The name of the schema where the table will be created. Default is a random string.
|
|
56
|
+
* `name` (str): The name of the table. Default is a random string.
|
|
57
|
+
* `ctas` (str): The CTAS statement to create the table. Default is `None`.
|
|
58
|
+
* `non_delta` (bool): If `True`, the table will be created as a non-delta table. Default is `False`.
|
|
59
|
+
* `external` (bool): If `True`, the table will be created as an external table. Default is `False`.
|
|
60
|
+
* `external_csv` (str): The location of the external CSV table. Default is `None`.
|
|
61
|
+
* `external_delta` (str): The location of the external Delta table. Default is `None`.
|
|
62
|
+
* `view` (bool): If `True`, the table will be created as a view. Default is `False`.
|
|
63
|
+
* `tbl_properties` (dict): The table properties. Default is `None`.
|
|
64
|
+
* `hiveserde_ddl` (str): The DDL statement to create the table. Default is `None`.
|
|
65
|
+
* `storage_override` (str): The storage location override. Default is `None`.
|
|
66
|
+
* `columns` (list): The list of columns. Default is `None`.
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
```python
|
|
70
|
+
def test_catalog_fixture(make_catalog, make_schema, make_table):
|
|
71
|
+
from_catalog = make_catalog()
|
|
72
|
+
from_schema = make_schema(catalog_name=from_catalog.name)
|
|
73
|
+
from_table_1 = make_table(catalog_name=from_catalog.name, schema_name=from_schema.name)
|
|
74
|
+
logger.info(f"Created new schema: {from_table_1}")
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def generate_sql_schema(columns: list[tuple[str, str]]) -> str:
|
|
79
|
+
"""Generate a SQL schema from columns."""
|
|
80
|
+
schema = "("
|
|
81
|
+
for index, (col_name, type_name) in enumerate(columns):
|
|
82
|
+
schema += escape_sql_identifier(col_name or str(index), maxsplit=0)
|
|
83
|
+
schema += f" {type_name}, "
|
|
84
|
+
schema = schema[:-2] + ")" # Remove the last ', '
|
|
85
|
+
return schema
|
|
86
|
+
|
|
87
|
+
def generate_sql_column_casting(existing_columns: list[tuple[str, str]], new_columns: list[tuple[str, str]]) -> str:
|
|
88
|
+
"""Generate the SQL to cast columns"""
|
|
89
|
+
if len(new_columns) > len(existing_columns):
|
|
90
|
+
raise ValueError(f"Too many columns: {new_columns}")
|
|
91
|
+
select_expressions = []
|
|
92
|
+
for index, ((existing_name, _), (new_name, new_type)) in enumerate(zip(existing_columns, new_columns)):
|
|
93
|
+
column_name_new = escape_sql_identifier(new_name or str(index), maxsplit=0)
|
|
94
|
+
select_expression = f"CAST({existing_name} AS {new_type}) AS {column_name_new}"
|
|
95
|
+
select_expressions.append(select_expression)
|
|
96
|
+
select = ", ".join(select_expressions)
|
|
97
|
+
return select
|
|
98
|
+
|
|
99
|
+
def create( # pylint: disable=too-many-locals,too-many-arguments,too-many-statements
|
|
100
|
+
*,
|
|
101
|
+
catalog_name="hive_metastore",
|
|
102
|
+
schema_name: str | None = None,
|
|
103
|
+
name: str | None = None,
|
|
104
|
+
ctas: str | None = None,
|
|
105
|
+
non_delta: bool = False,
|
|
106
|
+
external: bool = False,
|
|
107
|
+
external_csv: str | None = None,
|
|
108
|
+
external_delta: str | None = None,
|
|
109
|
+
view: bool = False,
|
|
110
|
+
tbl_properties: dict[str, str] | None = None,
|
|
111
|
+
hiveserde_ddl: str | None = None,
|
|
112
|
+
storage_override: str | None = None,
|
|
113
|
+
columns: list[tuple[str, str]] | None = None,
|
|
114
|
+
) -> TableInfo:
|
|
115
|
+
if schema_name is None:
|
|
116
|
+
schema = make_schema(catalog_name=catalog_name)
|
|
117
|
+
catalog_name = schema.catalog_name
|
|
118
|
+
schema_name = schema.name
|
|
119
|
+
if name is None:
|
|
120
|
+
name = f"ucx_T{make_random(4)}".lower()
|
|
121
|
+
table_type: TableType | None = None
|
|
122
|
+
data_source_format = None
|
|
123
|
+
storage_location = None
|
|
124
|
+
view_text = None
|
|
125
|
+
full_name = f"{catalog_name}.{schema_name}.{name}".lower()
|
|
126
|
+
ddl = f'CREATE {"VIEW" if view else "TABLE"} {full_name}'
|
|
127
|
+
if columns is None:
|
|
128
|
+
schema = "(id INT, value STRING)"
|
|
129
|
+
else:
|
|
130
|
+
schema = generate_sql_schema(columns)
|
|
131
|
+
if view:
|
|
132
|
+
table_type = TableType.VIEW
|
|
133
|
+
view_text = ctas
|
|
134
|
+
if ctas is not None:
|
|
135
|
+
# temporary (if not view)
|
|
136
|
+
ddl = f"{ddl} AS {ctas}"
|
|
137
|
+
elif non_delta:
|
|
138
|
+
table_type = TableType.EXTERNAL # pylint: disable=redefined-variable-type
|
|
139
|
+
data_source_format = DataSourceFormat.JSON
|
|
140
|
+
# DBFS locations are not purged; no suffix necessary.
|
|
141
|
+
storage_location = f"dbfs:/tmp/ucx_test_{make_random(4)}"
|
|
142
|
+
if columns is None:
|
|
143
|
+
select = "*"
|
|
144
|
+
else:
|
|
145
|
+
# These are the columns from the JSON dataset below
|
|
146
|
+
dataset_columns = [
|
|
147
|
+
('calories_burnt', 'STRING'),
|
|
148
|
+
('device_id', 'STRING'),
|
|
149
|
+
('id', 'STRING'),
|
|
150
|
+
('miles_walked', 'STRING'),
|
|
151
|
+
('num_steps', 'STRING'),
|
|
152
|
+
('timestamp', 'STRING'),
|
|
153
|
+
('user_id', 'STRING'),
|
|
154
|
+
('value', 'STRING'),
|
|
155
|
+
]
|
|
156
|
+
select = generate_sql_column_casting(dataset_columns, columns)
|
|
157
|
+
# Modified, otherwise it will identify the table as a DB Dataset
|
|
158
|
+
ddl = (
|
|
159
|
+
f"{ddl} USING json location '{storage_location}' as SELECT {select} FROM "
|
|
160
|
+
f"JSON.`dbfs:/databricks-datasets/iot-stream/data-device`"
|
|
161
|
+
)
|
|
162
|
+
elif external_csv is not None:
|
|
163
|
+
table_type = TableType.EXTERNAL
|
|
164
|
+
data_source_format = DataSourceFormat.CSV
|
|
165
|
+
storage_location = external_csv
|
|
166
|
+
ddl = f"{ddl} {schema} USING CSV OPTIONS (header=true) LOCATION '{storage_location}'"
|
|
167
|
+
elif external_delta is not None:
|
|
168
|
+
table_type = TableType.EXTERNAL
|
|
169
|
+
data_source_format = DataSourceFormat.DELTA
|
|
170
|
+
storage_location = external_delta
|
|
171
|
+
ddl = f"{ddl} {schema} LOCATION '{storage_location}'"
|
|
172
|
+
elif external:
|
|
173
|
+
# external table
|
|
174
|
+
table_type = TableType.EXTERNAL
|
|
175
|
+
data_source_format = DataSourceFormat.DELTASHARING
|
|
176
|
+
url = "s3a://databricks-datasets-oregon/delta-sharing/share/open-datasets.share"
|
|
177
|
+
storage_location = f"{url}#delta_sharing.default.lending_club"
|
|
178
|
+
ddl = f"{ddl} USING deltaSharing LOCATION '{storage_location}'"
|
|
179
|
+
else:
|
|
180
|
+
# managed table
|
|
181
|
+
table_type = TableType.MANAGED
|
|
182
|
+
data_source_format = DataSourceFormat.DELTA
|
|
183
|
+
storage_location = f"dbfs:/user/hive/warehouse/{schema_name}/{name}"
|
|
184
|
+
ddl = f"{ddl} {schema}"
|
|
185
|
+
if tbl_properties:
|
|
186
|
+
tbl_properties.update({"RemoveAfter": watchdog_remove_after})
|
|
187
|
+
else:
|
|
188
|
+
tbl_properties = {"RemoveAfter": watchdog_remove_after}
|
|
189
|
+
|
|
190
|
+
str_properties = ",".join([f" '{k}' = '{v}' " for k, v in tbl_properties.items()])
|
|
191
|
+
|
|
192
|
+
# table properties fails with CTAS statements
|
|
193
|
+
alter_table_tbl_properties = ""
|
|
194
|
+
if ctas or non_delta:
|
|
195
|
+
alter_table_tbl_properties = (
|
|
196
|
+
f'ALTER {"VIEW" if view else "TABLE"} {full_name} SET TBLPROPERTIES ({str_properties})'
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
ddl = f"{ddl} TBLPROPERTIES ({str_properties})"
|
|
200
|
+
|
|
201
|
+
if hiveserde_ddl:
|
|
202
|
+
ddl = hiveserde_ddl
|
|
203
|
+
data_source_format = None
|
|
204
|
+
table_type = TableType.EXTERNAL
|
|
205
|
+
storage_location = storage_override
|
|
206
|
+
|
|
207
|
+
sql_backend.execute(ddl)
|
|
208
|
+
|
|
209
|
+
# CTAS AND NON_DELTA does not support TBLPROPERTIES
|
|
210
|
+
if ctas or non_delta:
|
|
211
|
+
sql_backend.execute(alter_table_tbl_properties)
|
|
212
|
+
|
|
213
|
+
table_info = TableInfo(
|
|
214
|
+
catalog_name=catalog_name,
|
|
215
|
+
schema_name=schema_name,
|
|
216
|
+
name=name,
|
|
217
|
+
full_name=full_name,
|
|
218
|
+
properties=tbl_properties,
|
|
219
|
+
storage_location=storage_location,
|
|
220
|
+
table_type=table_type,
|
|
221
|
+
view_definition=view_text,
|
|
222
|
+
data_source_format=data_source_format,
|
|
223
|
+
)
|
|
224
|
+
path = f'explore/data/{table_info.catalog_name}/{table_info.schema_name}/{table_info.name}'
|
|
225
|
+
log_workspace_link(f'{table_info.full_name} schema', path)
|
|
226
|
+
return table_info
|
|
227
|
+
|
|
228
|
+
def remove(table_info: TableInfo):
|
|
229
|
+
try:
|
|
230
|
+
sql_backend.execute(f"DROP TABLE IF EXISTS {table_info.full_name}")
|
|
231
|
+
except DatabricksError as e:
|
|
232
|
+
if "Cannot drop a view" in str(e):
|
|
233
|
+
sql_backend.execute(f"DROP VIEW IF EXISTS {table_info.full_name}")
|
|
234
|
+
else:
|
|
235
|
+
raise e
|
|
236
|
+
|
|
237
|
+
yield from factory("table", create, remove)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@fixture
|
|
241
|
+
def make_schema(
|
|
242
|
+
sql_backend,
|
|
243
|
+
make_random,
|
|
244
|
+
log_workspace_link,
|
|
245
|
+
watchdog_remove_after,
|
|
246
|
+
) -> Generator[Callable[..., SchemaInfo], None, None]:
|
|
247
|
+
"""
|
|
248
|
+
Create a schema and return its info. Remove it after the test. Returns instance of `databricks.sdk.service.catalog.SchemaInfo`.
|
|
249
|
+
|
|
250
|
+
Keyword Arguments:
|
|
251
|
+
* `catalog_name` (str): The name of the catalog where the schema will be created. Default is `hive_metastore`.
|
|
252
|
+
* `name` (str): The name of the schema. Default is a random string.
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
```python
|
|
256
|
+
def test_catalog_fixture(make_catalog, make_schema, make_table):
|
|
257
|
+
from_catalog = make_catalog()
|
|
258
|
+
from_schema = make_schema(catalog_name=from_catalog.name)
|
|
259
|
+
from_table_1 = make_table(catalog_name=from_catalog.name, schema_name=from_schema.name)
|
|
260
|
+
logger.info(f"Created new schema: {from_table_1}")
|
|
261
|
+
```
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def create(*, catalog_name: str = "hive_metastore", name: str | None = None) -> SchemaInfo:
|
|
265
|
+
if name is None:
|
|
266
|
+
name = f"dummy_S{make_random(4)}".lower()
|
|
267
|
+
full_name = f"{catalog_name}.{name}".lower()
|
|
268
|
+
sql_backend.execute(f"CREATE SCHEMA {full_name} WITH DBPROPERTIES (RemoveAfter={watchdog_remove_after})")
|
|
269
|
+
schema_info = SchemaInfo(catalog_name=catalog_name, name=name, full_name=full_name)
|
|
270
|
+
path = f'explore/data/{schema_info.catalog_name}/{schema_info.name}'
|
|
271
|
+
log_workspace_link(f'{schema_info.full_name} schema', path)
|
|
272
|
+
return schema_info
|
|
273
|
+
|
|
274
|
+
def remove(schema_info: SchemaInfo):
|
|
275
|
+
sql_backend.execute(f"DROP SCHEMA IF EXISTS {schema_info.full_name} CASCADE")
|
|
276
|
+
|
|
277
|
+
yield from factory("schema", create, remove)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@fixture
|
|
281
|
+
def make_catalog(ws, sql_backend, make_random, log_workspace_link) -> Generator[Callable[..., CatalogInfo], None, None]:
|
|
282
|
+
"""
|
|
283
|
+
Create a catalog and return its info. Remove it after the test.
|
|
284
|
+
Returns instance of `databricks.sdk.service.catalog.CatalogInfo`.
|
|
285
|
+
|
|
286
|
+
Usage:
|
|
287
|
+
```python
|
|
288
|
+
def test_catalog_fixture(make_catalog, make_schema, make_table):
|
|
289
|
+
from_catalog = make_catalog()
|
|
290
|
+
from_schema = make_schema(catalog_name=from_catalog.name)
|
|
291
|
+
from_table_1 = make_table(catalog_name=from_catalog.name, schema_name=from_schema.name)
|
|
292
|
+
logger.info(f"Created new schema: {from_table_1}")
|
|
293
|
+
```
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def create() -> CatalogInfo:
|
|
297
|
+
# Warning: As of 2024-09-04 there is no way to mark this catalog for protection against the watchdog.
|
|
298
|
+
# Ref: https://github.com/databrickslabs/watchdog/blob/cdc97afdac1567e89d3b39d938f066fd6267b3ba/scan/objects/uc.go#L68
|
|
299
|
+
name = f"dummy_C{make_random(4)}".lower()
|
|
300
|
+
sql_backend.execute(f"CREATE CATALOG {name}")
|
|
301
|
+
catalog_info = ws.catalogs.get(name)
|
|
302
|
+
log_workspace_link(f'{name} catalog', f'explore/data/{name}')
|
|
303
|
+
return catalog_info
|
|
304
|
+
|
|
305
|
+
yield from factory(
|
|
306
|
+
"catalog",
|
|
307
|
+
create,
|
|
308
|
+
lambda catalog_info: ws.catalogs.delete(catalog_info.full_name, force=True),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@fixture
|
|
313
|
+
def make_udf(
|
|
314
|
+
ws,
|
|
315
|
+
env_or_skip,
|
|
316
|
+
sql_backend,
|
|
317
|
+
make_schema,
|
|
318
|
+
make_random,
|
|
319
|
+
) -> Generator[Callable[..., FunctionInfo], None, None]:
|
|
320
|
+
"""
|
|
321
|
+
Create a UDF and return its info. Remove it after the test. Returns instance of `databricks.sdk.service.catalog.FunctionInfo`.
|
|
322
|
+
|
|
323
|
+
Keyword Arguments:
|
|
324
|
+
* `catalog_name` (str): The name of the catalog where the UDF will be created. Default is `hive_metastore`.
|
|
325
|
+
* `schema_name` (str): The name of the schema where the UDF will be created. Default is a random string.
|
|
326
|
+
* `name` (str): The name of the UDF. Default is a random string.
|
|
327
|
+
* `hive_udf` (bool): If `True`, the UDF will be created as a Hive UDF. Default is `False`.
|
|
328
|
+
|
|
329
|
+
Usage:
|
|
330
|
+
```python
|
|
331
|
+
def test_make_some_udfs(make_schema, make_udf):
|
|
332
|
+
schema_a = make_schema(catalog_name="hive_metastore")
|
|
333
|
+
make_udf(schema_name=schema_a.name)
|
|
334
|
+
make_udf(schema_name=schema_a.name, hive_udf=True)
|
|
335
|
+
```
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def create(
|
|
339
|
+
*,
|
|
340
|
+
catalog_name="hive_metastore",
|
|
341
|
+
schema_name: str | None = None,
|
|
342
|
+
name: str | None = None,
|
|
343
|
+
hive_udf: bool = False,
|
|
344
|
+
) -> FunctionInfo:
|
|
345
|
+
if schema_name is None:
|
|
346
|
+
schema = make_schema(catalog_name=catalog_name)
|
|
347
|
+
catalog_name = schema.catalog_name
|
|
348
|
+
schema_name = schema.name
|
|
349
|
+
|
|
350
|
+
if name is None:
|
|
351
|
+
name = f"ucx_t{make_random(4).lower()}"
|
|
352
|
+
|
|
353
|
+
# Note: the Watchdog does not explicitly scan for functions; they are purged along with their parent schema.
|
|
354
|
+
# As such the function can't be marked (and doesn't need to be if the schema as marked) for purge protection.
|
|
355
|
+
|
|
356
|
+
full_name = f"{catalog_name}.{schema_name}.{name}".lower()
|
|
357
|
+
if hive_udf:
|
|
358
|
+
cmd_exec = CommandExecutor(
|
|
359
|
+
ws.clusters,
|
|
360
|
+
ws.command_execution,
|
|
361
|
+
lambda: env_or_skip("TEST_DEFAULT_CLUSTER_ID"),
|
|
362
|
+
language=Language.SQL,
|
|
363
|
+
)
|
|
364
|
+
ddl = f"CREATE FUNCTION {full_name} AS 'org.apache.hadoop.hive.ql.udf.generic.GenericUDFAbs';"
|
|
365
|
+
cmd_exec.run(ddl)
|
|
366
|
+
else:
|
|
367
|
+
ddl = f"CREATE FUNCTION {full_name}(x INT) RETURNS FLOAT CONTAINS SQL DETERMINISTIC RETURN 0;"
|
|
368
|
+
sql_backend.execute(ddl)
|
|
369
|
+
udf_info = FunctionInfo(
|
|
370
|
+
catalog_name=catalog_name,
|
|
371
|
+
schema_name=schema_name,
|
|
372
|
+
name=name,
|
|
373
|
+
full_name=full_name,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
logger.info(f"Function {udf_info.full_name} created")
|
|
377
|
+
return udf_info
|
|
378
|
+
|
|
379
|
+
def remove(udf_info: FunctionInfo):
|
|
380
|
+
sql_backend.execute(f"DROP FUNCTION IF EXISTS {udf_info.full_name}")
|
|
381
|
+
|
|
382
|
+
yield from factory("table", create, remove)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@fixture
|
|
386
|
+
def make_storage_credential(ws) -> Generator[Callable[..., StorageCredentialInfo], None, None]:
|
|
387
|
+
"""
|
|
388
|
+
Create a storage credential and return its info. Remove it after the test. Returns instance of `databricks.sdk.service.catalog.StorageCredentialInfo`.
|
|
389
|
+
|
|
390
|
+
Keyword Arguments:
|
|
391
|
+
* `credential_name` (str): The name of the storage credential. Default is a random string.
|
|
392
|
+
* `application_id` (str): The application ID for the Azure service principal. Default is an empty string.
|
|
393
|
+
* `client_secret` (str): The client secret for the Azure service principal. Default is an empty string.
|
|
394
|
+
* `directory_id` (str): The directory ID for the Azure service principal. Default is an empty string.
|
|
395
|
+
* `aws_iam_role_arn` (str): The ARN of the AWS IAM role. Default is an empty string.
|
|
396
|
+
* `read_only` (bool): If `True`, the storage credential will be read-only. Default is `False`.
|
|
397
|
+
|
|
398
|
+
Usage:
|
|
399
|
+
```python
|
|
400
|
+
def test_storage_credential(env_or_skip, make_storage_credential, make_random):
|
|
401
|
+
random = make_random(6).lower()
|
|
402
|
+
credential_name = f"dummy-{random}"
|
|
403
|
+
make_storage_credential(
|
|
404
|
+
credential_name=credential_name,
|
|
405
|
+
aws_iam_role_arn=env_or_skip("TEST_UBER_ROLE_ID"),
|
|
406
|
+
)
|
|
407
|
+
```
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
def create(
|
|
411
|
+
*,
|
|
412
|
+
credential_name: str,
|
|
413
|
+
application_id: str = "",
|
|
414
|
+
client_secret: str = "",
|
|
415
|
+
directory_id: str = "",
|
|
416
|
+
aws_iam_role_arn: str = "",
|
|
417
|
+
read_only=False,
|
|
418
|
+
) -> StorageCredentialInfo:
|
|
419
|
+
if aws_iam_role_arn != "":
|
|
420
|
+
storage_credential = ws.storage_credentials.create(
|
|
421
|
+
credential_name, aws_iam_role=AwsIamRoleRequest(role_arn=aws_iam_role_arn), read_only=read_only
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
azure_service_principal = AzureServicePrincipal(directory_id, application_id, client_secret)
|
|
425
|
+
storage_credential = ws.storage_credentials.create(
|
|
426
|
+
credential_name, azure_service_principal=azure_service_principal, read_only=read_only
|
|
427
|
+
)
|
|
428
|
+
return storage_credential
|
|
429
|
+
|
|
430
|
+
def remove(storage_credential: StorageCredentialInfo):
|
|
431
|
+
ws.storage_credentials.delete(storage_credential.name, force=True)
|
|
432
|
+
|
|
433
|
+
yield from factory("storage_credential", create, remove)
|