pixeltable 0.2.26__py3-none-any.whl → 0.5.7__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.
- pixeltable/__init__.py +83 -19
- pixeltable/_query.py +1444 -0
- pixeltable/_version.py +1 -0
- pixeltable/catalog/__init__.py +7 -4
- pixeltable/catalog/catalog.py +2394 -119
- pixeltable/catalog/column.py +225 -104
- pixeltable/catalog/dir.py +38 -9
- pixeltable/catalog/globals.py +53 -34
- pixeltable/catalog/insertable_table.py +265 -115
- pixeltable/catalog/path.py +80 -17
- pixeltable/catalog/schema_object.py +28 -43
- pixeltable/catalog/table.py +1270 -677
- pixeltable/catalog/table_metadata.py +103 -0
- pixeltable/catalog/table_version.py +1270 -751
- pixeltable/catalog/table_version_handle.py +109 -0
- pixeltable/catalog/table_version_path.py +137 -42
- pixeltable/catalog/tbl_ops.py +53 -0
- pixeltable/catalog/update_status.py +191 -0
- pixeltable/catalog/view.py +251 -134
- pixeltable/config.py +215 -0
- pixeltable/env.py +736 -285
- pixeltable/exceptions.py +26 -2
- pixeltable/exec/__init__.py +7 -2
- pixeltable/exec/aggregation_node.py +39 -21
- pixeltable/exec/cache_prefetch_node.py +87 -109
- pixeltable/exec/cell_materialization_node.py +268 -0
- pixeltable/exec/cell_reconstruction_node.py +168 -0
- pixeltable/exec/component_iteration_node.py +25 -28
- pixeltable/exec/data_row_batch.py +11 -46
- pixeltable/exec/exec_context.py +26 -11
- pixeltable/exec/exec_node.py +35 -27
- pixeltable/exec/expr_eval/__init__.py +3 -0
- pixeltable/exec/expr_eval/evaluators.py +365 -0
- pixeltable/exec/expr_eval/expr_eval_node.py +413 -0
- pixeltable/exec/expr_eval/globals.py +200 -0
- pixeltable/exec/expr_eval/row_buffer.py +74 -0
- pixeltable/exec/expr_eval/schedulers.py +413 -0
- pixeltable/exec/globals.py +35 -0
- pixeltable/exec/in_memory_data_node.py +35 -27
- pixeltable/exec/object_store_save_node.py +293 -0
- pixeltable/exec/row_update_node.py +44 -29
- pixeltable/exec/sql_node.py +414 -115
- pixeltable/exprs/__init__.py +8 -5
- pixeltable/exprs/arithmetic_expr.py +79 -45
- pixeltable/exprs/array_slice.py +5 -5
- pixeltable/exprs/column_property_ref.py +40 -26
- pixeltable/exprs/column_ref.py +254 -61
- pixeltable/exprs/comparison.py +14 -9
- pixeltable/exprs/compound_predicate.py +9 -10
- pixeltable/exprs/data_row.py +213 -72
- pixeltable/exprs/expr.py +270 -104
- pixeltable/exprs/expr_dict.py +6 -5
- pixeltable/exprs/expr_set.py +20 -11
- pixeltable/exprs/function_call.py +383 -284
- pixeltable/exprs/globals.py +18 -5
- pixeltable/exprs/in_predicate.py +7 -7
- pixeltable/exprs/inline_expr.py +37 -37
- pixeltable/exprs/is_null.py +8 -4
- pixeltable/exprs/json_mapper.py +120 -54
- pixeltable/exprs/json_path.py +90 -60
- pixeltable/exprs/literal.py +61 -16
- pixeltable/exprs/method_ref.py +7 -6
- pixeltable/exprs/object_ref.py +19 -8
- pixeltable/exprs/row_builder.py +238 -75
- pixeltable/exprs/rowid_ref.py +53 -15
- pixeltable/exprs/similarity_expr.py +65 -50
- pixeltable/exprs/sql_element_cache.py +5 -5
- pixeltable/exprs/string_op.py +107 -0
- pixeltable/exprs/type_cast.py +25 -13
- pixeltable/exprs/variable.py +2 -2
- pixeltable/func/__init__.py +9 -5
- pixeltable/func/aggregate_function.py +197 -92
- pixeltable/func/callable_function.py +119 -35
- pixeltable/func/expr_template_function.py +101 -48
- pixeltable/func/function.py +375 -62
- pixeltable/func/function_registry.py +20 -19
- pixeltable/func/globals.py +6 -5
- pixeltable/func/mcp.py +74 -0
- pixeltable/func/query_template_function.py +151 -35
- pixeltable/func/signature.py +178 -49
- pixeltable/func/tools.py +164 -0
- pixeltable/func/udf.py +176 -53
- pixeltable/functions/__init__.py +44 -4
- pixeltable/functions/anthropic.py +226 -47
- pixeltable/functions/audio.py +148 -11
- pixeltable/functions/bedrock.py +137 -0
- pixeltable/functions/date.py +188 -0
- pixeltable/functions/deepseek.py +113 -0
- pixeltable/functions/document.py +81 -0
- pixeltable/functions/fal.py +76 -0
- pixeltable/functions/fireworks.py +72 -20
- pixeltable/functions/gemini.py +249 -0
- pixeltable/functions/globals.py +208 -53
- pixeltable/functions/groq.py +108 -0
- pixeltable/functions/huggingface.py +1088 -95
- pixeltable/functions/image.py +155 -84
- pixeltable/functions/json.py +8 -11
- pixeltable/functions/llama_cpp.py +31 -19
- pixeltable/functions/math.py +169 -0
- pixeltable/functions/mistralai.py +50 -75
- pixeltable/functions/net.py +70 -0
- pixeltable/functions/ollama.py +29 -36
- pixeltable/functions/openai.py +548 -160
- pixeltable/functions/openrouter.py +143 -0
- pixeltable/functions/replicate.py +15 -14
- pixeltable/functions/reve.py +250 -0
- pixeltable/functions/string.py +310 -85
- pixeltable/functions/timestamp.py +37 -19
- pixeltable/functions/together.py +77 -120
- pixeltable/functions/twelvelabs.py +188 -0
- pixeltable/functions/util.py +7 -2
- pixeltable/functions/uuid.py +30 -0
- pixeltable/functions/video.py +1528 -117
- pixeltable/functions/vision.py +26 -26
- pixeltable/functions/voyageai.py +289 -0
- pixeltable/functions/whisper.py +19 -10
- pixeltable/functions/whisperx.py +179 -0
- pixeltable/functions/yolox.py +112 -0
- pixeltable/globals.py +716 -236
- pixeltable/index/__init__.py +3 -1
- pixeltable/index/base.py +17 -21
- pixeltable/index/btree.py +32 -22
- pixeltable/index/embedding_index.py +155 -92
- pixeltable/io/__init__.py +12 -7
- pixeltable/io/datarows.py +140 -0
- pixeltable/io/external_store.py +83 -125
- pixeltable/io/fiftyone.py +24 -33
- pixeltable/io/globals.py +47 -182
- pixeltable/io/hf_datasets.py +96 -127
- pixeltable/io/label_studio.py +171 -156
- pixeltable/io/lancedb.py +3 -0
- pixeltable/io/pandas.py +136 -115
- pixeltable/io/parquet.py +40 -153
- pixeltable/io/table_data_conduit.py +702 -0
- pixeltable/io/utils.py +100 -0
- pixeltable/iterators/__init__.py +8 -4
- pixeltable/iterators/audio.py +207 -0
- pixeltable/iterators/base.py +9 -3
- pixeltable/iterators/document.py +144 -87
- pixeltable/iterators/image.py +17 -38
- pixeltable/iterators/string.py +15 -12
- pixeltable/iterators/video.py +523 -127
- pixeltable/metadata/__init__.py +33 -8
- pixeltable/metadata/converters/convert_10.py +2 -3
- pixeltable/metadata/converters/convert_13.py +2 -2
- pixeltable/metadata/converters/convert_15.py +15 -11
- pixeltable/metadata/converters/convert_16.py +4 -5
- pixeltable/metadata/converters/convert_17.py +4 -5
- pixeltable/metadata/converters/convert_18.py +4 -6
- pixeltable/metadata/converters/convert_19.py +6 -9
- pixeltable/metadata/converters/convert_20.py +3 -6
- pixeltable/metadata/converters/convert_21.py +6 -8
- pixeltable/metadata/converters/convert_22.py +3 -2
- pixeltable/metadata/converters/convert_23.py +33 -0
- pixeltable/metadata/converters/convert_24.py +55 -0
- pixeltable/metadata/converters/convert_25.py +19 -0
- pixeltable/metadata/converters/convert_26.py +23 -0
- pixeltable/metadata/converters/convert_27.py +29 -0
- pixeltable/metadata/converters/convert_28.py +13 -0
- pixeltable/metadata/converters/convert_29.py +110 -0
- pixeltable/metadata/converters/convert_30.py +63 -0
- pixeltable/metadata/converters/convert_31.py +11 -0
- pixeltable/metadata/converters/convert_32.py +15 -0
- pixeltable/metadata/converters/convert_33.py +17 -0
- pixeltable/metadata/converters/convert_34.py +21 -0
- pixeltable/metadata/converters/convert_35.py +9 -0
- pixeltable/metadata/converters/convert_36.py +38 -0
- pixeltable/metadata/converters/convert_37.py +15 -0
- pixeltable/metadata/converters/convert_38.py +39 -0
- pixeltable/metadata/converters/convert_39.py +124 -0
- pixeltable/metadata/converters/convert_40.py +73 -0
- pixeltable/metadata/converters/convert_41.py +12 -0
- pixeltable/metadata/converters/convert_42.py +9 -0
- pixeltable/metadata/converters/convert_43.py +44 -0
- pixeltable/metadata/converters/util.py +44 -18
- pixeltable/metadata/notes.py +21 -0
- pixeltable/metadata/schema.py +185 -42
- pixeltable/metadata/utils.py +74 -0
- pixeltable/mypy/__init__.py +3 -0
- pixeltable/mypy/mypy_plugin.py +123 -0
- pixeltable/plan.py +616 -225
- pixeltable/share/__init__.py +3 -0
- pixeltable/share/packager.py +797 -0
- pixeltable/share/protocol/__init__.py +33 -0
- pixeltable/share/protocol/common.py +165 -0
- pixeltable/share/protocol/operation_types.py +33 -0
- pixeltable/share/protocol/replica.py +119 -0
- pixeltable/share/publish.py +349 -0
- pixeltable/store.py +398 -232
- pixeltable/type_system.py +730 -267
- pixeltable/utils/__init__.py +40 -0
- pixeltable/utils/arrow.py +201 -29
- pixeltable/utils/av.py +298 -0
- pixeltable/utils/azure_store.py +346 -0
- pixeltable/utils/coco.py +26 -27
- pixeltable/utils/code.py +4 -4
- pixeltable/utils/console_output.py +46 -0
- pixeltable/utils/coroutine.py +24 -0
- pixeltable/utils/dbms.py +92 -0
- pixeltable/utils/description_helper.py +11 -12
- pixeltable/utils/documents.py +60 -61
- pixeltable/utils/exception_handler.py +36 -0
- pixeltable/utils/filecache.py +38 -22
- pixeltable/utils/formatter.py +88 -51
- pixeltable/utils/gcs_store.py +295 -0
- pixeltable/utils/http.py +133 -0
- pixeltable/utils/http_server.py +14 -13
- pixeltable/utils/iceberg.py +13 -0
- pixeltable/utils/image.py +17 -0
- pixeltable/utils/lancedb.py +90 -0
- pixeltable/utils/local_store.py +322 -0
- pixeltable/utils/misc.py +5 -0
- pixeltable/utils/object_stores.py +573 -0
- pixeltable/utils/pydantic.py +60 -0
- pixeltable/utils/pytorch.py +20 -20
- pixeltable/utils/s3_store.py +527 -0
- pixeltable/utils/sql.py +32 -5
- pixeltable/utils/system.py +30 -0
- pixeltable/utils/transactional_directory.py +4 -3
- pixeltable-0.5.7.dist-info/METADATA +579 -0
- pixeltable-0.5.7.dist-info/RECORD +227 -0
- {pixeltable-0.2.26.dist-info → pixeltable-0.5.7.dist-info}/WHEEL +1 -1
- pixeltable-0.5.7.dist-info/entry_points.txt +2 -0
- pixeltable/__version__.py +0 -3
- pixeltable/catalog/named_function.py +0 -36
- pixeltable/catalog/path_dict.py +0 -141
- pixeltable/dataframe.py +0 -894
- pixeltable/exec/expr_eval_node.py +0 -232
- pixeltable/ext/__init__.py +0 -14
- pixeltable/ext/functions/__init__.py +0 -8
- pixeltable/ext/functions/whisperx.py +0 -77
- pixeltable/ext/functions/yolox.py +0 -157
- pixeltable/tool/create_test_db_dump.py +0 -311
- pixeltable/tool/create_test_video.py +0 -81
- pixeltable/tool/doc_plugins/griffe.py +0 -50
- pixeltable/tool/doc_plugins/mkdocstrings.py +0 -6
- pixeltable/tool/doc_plugins/templates/material/udf.html.jinja +0 -135
- pixeltable/tool/embed_udf.py +0 -9
- pixeltable/tool/mypy_plugin.py +0 -55
- pixeltable/utils/media_store.py +0 -76
- pixeltable/utils/s3.py +0 -16
- pixeltable-0.2.26.dist-info/METADATA +0 -400
- pixeltable-0.2.26.dist-info/RECORD +0 -156
- pixeltable-0.2.26.dist-info/entry_points.txt +0 -3
- {pixeltable-0.2.26.dist-info → pixeltable-0.5.7.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Iterator
|
|
9
|
+
|
|
10
|
+
from google.api_core.exceptions import GoogleAPIError
|
|
11
|
+
from google.cloud import storage # type: ignore[attr-defined]
|
|
12
|
+
from google.cloud.exceptions import Forbidden, NotFound
|
|
13
|
+
from google.cloud.storage.client import Client # type: ignore[import-untyped]
|
|
14
|
+
|
|
15
|
+
from pixeltable import env, exceptions as excs
|
|
16
|
+
from pixeltable.utils.object_stores import ObjectPath, ObjectStoreBase, StorageObjectAddress, StorageTarget
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pixeltable.catalog import Column
|
|
20
|
+
|
|
21
|
+
_logger = logging.getLogger('pixeltable')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@env.register_client('gcs_store')
|
|
25
|
+
def _() -> 'Client':
|
|
26
|
+
"""Create and return a GCS client, using default credentials if available,
|
|
27
|
+
otherwise creating an anonymous client for public buckets.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
# Create a client with default credentials
|
|
31
|
+
# Note that if the default credentials have expired, gcloud will still create a client,
|
|
32
|
+
# which will report the expiry error when it is used.
|
|
33
|
+
# To create and use an anonymous client, expired credentials must be removed.
|
|
34
|
+
# For application default credentials, delete the file in ~/.config/gcloud/, or
|
|
35
|
+
# gcloud auth application-default revoke
|
|
36
|
+
# OR
|
|
37
|
+
# For service account keys, you must delete the downloaded key file.
|
|
38
|
+
client = storage.Client()
|
|
39
|
+
return client
|
|
40
|
+
except Exception:
|
|
41
|
+
# If no credentials are found, create an anonymous client which can be used for public buckets.
|
|
42
|
+
client = storage.Client.create_anonymous_client()
|
|
43
|
+
return client
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GCSStore(ObjectStoreBase):
|
|
47
|
+
"""Class to handle Google Cloud Storage operations."""
|
|
48
|
+
|
|
49
|
+
# URI of the GCS bucket in the format gs://bucket_name/prefix/
|
|
50
|
+
# Always ends with a slash
|
|
51
|
+
__base_uri: str
|
|
52
|
+
|
|
53
|
+
# bucket name extracted from the URI
|
|
54
|
+
__bucket_name: str
|
|
55
|
+
|
|
56
|
+
# prefix path within the bucket, either empty or ending with a slash
|
|
57
|
+
__prefix_name: str
|
|
58
|
+
|
|
59
|
+
# The parsed form of the given destination address
|
|
60
|
+
soa: StorageObjectAddress
|
|
61
|
+
|
|
62
|
+
def __init__(self, soa: StorageObjectAddress):
|
|
63
|
+
assert soa.storage_target == StorageTarget.GCS_STORE, f'Expected storage_target "gs", got {soa.storage_target}'
|
|
64
|
+
self.soa = soa
|
|
65
|
+
self.__base_uri = soa.prefix_free_uri + soa.prefix
|
|
66
|
+
self.__bucket_name = soa.container
|
|
67
|
+
self.__prefix_name = soa.prefix
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def client(cls) -> 'Client':
|
|
71
|
+
"""Return the GCS client."""
|
|
72
|
+
return env.Env.get().get_client('gcs_store')
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def bucket_name(self) -> str:
|
|
76
|
+
"""Return the bucket name from the base URI."""
|
|
77
|
+
return self.__bucket_name
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def prefix(self) -> str:
|
|
81
|
+
"""Return the prefix from the base URI."""
|
|
82
|
+
return self.__prefix_name
|
|
83
|
+
|
|
84
|
+
def validate(self, error_col_name: str) -> str | None:
|
|
85
|
+
"""
|
|
86
|
+
Checks if the URI exists.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
str: The base URI if the GCS bucket exists and is accessible, None otherwise.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
client = self.client()
|
|
93
|
+
bucket = client.bucket(self.bucket_name)
|
|
94
|
+
blobs = bucket.list_blobs(max_results=1)
|
|
95
|
+
# This will raise an exception if the destination doesn't exist or cannot be listed
|
|
96
|
+
_ = list(blobs) # Force evaluation to check access
|
|
97
|
+
return self.__base_uri
|
|
98
|
+
except (NotFound, Forbidden, GoogleAPIError) as e:
|
|
99
|
+
self.handle_gcs_error(e, self.bucket_name, f'validate bucket {error_col_name}')
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def _prepare_uri_raw(self, tbl_id: uuid.UUID, col_id: int, tbl_version: int, ext: str | None = None) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Construct a new, unique URI for a persisted media file.
|
|
105
|
+
"""
|
|
106
|
+
prefix, filename = ObjectPath.create_prefix_raw(tbl_id, col_id, tbl_version, ext)
|
|
107
|
+
parent = f'{self.__base_uri}{prefix}'
|
|
108
|
+
return f'{parent}/{filename}'
|
|
109
|
+
|
|
110
|
+
def _prepare_uri(self, col: Column, ext: str | None = None) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Construct a new, unique URI for a persisted media file.
|
|
113
|
+
"""
|
|
114
|
+
assert col.get_tbl() is not None, 'Column must be associated with a table'
|
|
115
|
+
return self._prepare_uri_raw(col.get_tbl().id, col.id, col.get_tbl().version, ext=ext)
|
|
116
|
+
|
|
117
|
+
def copy_local_file(self, col: Column, src_path: Path) -> str:
|
|
118
|
+
"""Copy a local file, and return its new URL"""
|
|
119
|
+
new_file_uri = self._prepare_uri(col, ext=src_path.suffix)
|
|
120
|
+
parsed = urllib.parse.urlparse(new_file_uri)
|
|
121
|
+
blob_name = parsed.path.lstrip('/')
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
client = self.client()
|
|
125
|
+
bucket = client.bucket(self.bucket_name)
|
|
126
|
+
blob = bucket.blob(blob_name)
|
|
127
|
+
blob.upload_from_filename(str(src_path))
|
|
128
|
+
_logger.debug(f'Media Storage: copied {src_path} to {new_file_uri}')
|
|
129
|
+
return new_file_uri
|
|
130
|
+
except GoogleAPIError as e:
|
|
131
|
+
self.handle_gcs_error(e, self.bucket_name, f'upload file {src_path}')
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
def copy_object_to_local_file(self, src_path: str, dest_path: Path) -> None:
|
|
135
|
+
"""Copies an object to a local file. Thread safe"""
|
|
136
|
+
try:
|
|
137
|
+
client = self.client()
|
|
138
|
+
bucket = client.bucket(self.bucket_name)
|
|
139
|
+
blob = bucket.blob(self.prefix + src_path)
|
|
140
|
+
blob.download_to_filename(str(dest_path))
|
|
141
|
+
except GoogleAPIError as e:
|
|
142
|
+
self.handle_gcs_error(e, self.bucket_name, f'download file {src_path}')
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def _get_filtered_objects(self, bucket: Any, tbl_id: uuid.UUID, tbl_version: int | None = None) -> Iterator:
|
|
146
|
+
"""Private method to get filtered objects for a table, optionally filtered by version.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
tbl_id: Table UUID to filter by
|
|
150
|
+
tbl_version: Optional table version to filter by
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Tuple of (iterator over GCS objects matching the criteria, bucket object)
|
|
154
|
+
"""
|
|
155
|
+
table_prefix = ObjectPath.table_prefix(tbl_id)
|
|
156
|
+
prefix = f'{self.prefix}{table_prefix}/'
|
|
157
|
+
|
|
158
|
+
if tbl_version is None:
|
|
159
|
+
# Return all blobs with the table prefix
|
|
160
|
+
blob_iterator = bucket.list_blobs(prefix=prefix)
|
|
161
|
+
else:
|
|
162
|
+
# Filter by both table_id and table_version using the ObjectPath pattern
|
|
163
|
+
# Pattern: tbl_id_col_id_version_uuid
|
|
164
|
+
version_pattern = re.compile(rf'{re.escape(table_prefix)}_\d+_{re.escape(str(tbl_version))}_[0-9a-fA-F]+.*')
|
|
165
|
+
# Return filtered collection - this still uses lazy loading
|
|
166
|
+
all_blobs = bucket.list_blobs(prefix=prefix)
|
|
167
|
+
blob_iterator = (blob for blob in all_blobs if version_pattern.match(blob.name.split('/')[-1]))
|
|
168
|
+
|
|
169
|
+
return blob_iterator
|
|
170
|
+
|
|
171
|
+
def count(self, tbl_id: uuid.UUID, tbl_version: int | None = None) -> int:
|
|
172
|
+
"""Count the number of files belonging to tbl_id. If tbl_version is not None,
|
|
173
|
+
count only those files belonging to the specified tbl_version.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
tbl_id: Table UUID to count objects for
|
|
177
|
+
tbl_version: Optional table version to filter by
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Number of objects matching the criteria
|
|
181
|
+
"""
|
|
182
|
+
assert tbl_id is not None
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
client = self.client()
|
|
186
|
+
bucket = client.bucket(self.bucket_name)
|
|
187
|
+
|
|
188
|
+
blob_iterator = self._get_filtered_objects(bucket, tbl_id, tbl_version)
|
|
189
|
+
|
|
190
|
+
return sum(1 for _ in blob_iterator)
|
|
191
|
+
|
|
192
|
+
except GoogleAPIError as e:
|
|
193
|
+
self.handle_gcs_error(e, self.bucket_name, f'setup iterator {self.prefix}')
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
def delete(self, tbl_id: uuid.UUID, tbl_version: int | None = None) -> int:
|
|
197
|
+
"""Delete all files belonging to tbl_id. If tbl_version is not None, delete
|
|
198
|
+
only those files belonging to the specified tbl_version.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
tbl_id: Table UUID to delete objects for
|
|
202
|
+
tbl_version: Optional table version to filter by
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Number of objects deleted
|
|
206
|
+
"""
|
|
207
|
+
assert tbl_id is not None
|
|
208
|
+
total_deleted = 0
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
client = self.client()
|
|
212
|
+
bucket = client.bucket(self.bucket_name)
|
|
213
|
+
blob_iterator = self._get_filtered_objects(bucket, tbl_id, tbl_version)
|
|
214
|
+
|
|
215
|
+
# Collect blob names for batch deletion
|
|
216
|
+
blobs_to_delete = []
|
|
217
|
+
|
|
218
|
+
for blob in blob_iterator:
|
|
219
|
+
blobs_to_delete.append(blob)
|
|
220
|
+
|
|
221
|
+
# Process in batches for efficiency
|
|
222
|
+
if len(blobs_to_delete) >= 100:
|
|
223
|
+
with client.batch():
|
|
224
|
+
for b in blobs_to_delete:
|
|
225
|
+
b.delete()
|
|
226
|
+
total_deleted += len(blobs_to_delete)
|
|
227
|
+
blobs_to_delete = []
|
|
228
|
+
|
|
229
|
+
# Delete any remaining blobs in the final batch
|
|
230
|
+
if len(blobs_to_delete) > 0:
|
|
231
|
+
with client.batch():
|
|
232
|
+
for b in blobs_to_delete:
|
|
233
|
+
b.delete()
|
|
234
|
+
total_deleted += len(blobs_to_delete)
|
|
235
|
+
|
|
236
|
+
return total_deleted
|
|
237
|
+
|
|
238
|
+
except GoogleAPIError as e:
|
|
239
|
+
self.handle_gcs_error(e, self.bucket_name, f'deleting with {self.prefix}')
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
def list_objects(self, return_uri: bool, n_max: int = 10) -> list[str]:
|
|
243
|
+
"""Return a list of objects found in the specified destination bucket.
|
|
244
|
+
Each returned object includes the full set of prefixes.
|
|
245
|
+
if return_uri is True, full URI's are returned; otherwise, just the object keys.
|
|
246
|
+
"""
|
|
247
|
+
p = self.soa.prefix_free_uri if return_uri else ''
|
|
248
|
+
gcs_client = self.client()
|
|
249
|
+
r: list[str] = []
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
bucket = gcs_client.bucket(self.bucket_name)
|
|
253
|
+
# List blobs with the given prefix, limiting to n_max
|
|
254
|
+
blobs = bucket.list_blobs(prefix=self.prefix, max_results=n_max)
|
|
255
|
+
|
|
256
|
+
for blob in blobs:
|
|
257
|
+
r.append(f'{p}{blob.name}')
|
|
258
|
+
if len(r) >= n_max:
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
except GoogleAPIError as e:
|
|
262
|
+
self.handle_gcs_error(e, self.bucket_name, f'list objects from {self.prefix}')
|
|
263
|
+
return r
|
|
264
|
+
|
|
265
|
+
def create_presigned_url(self, soa: StorageObjectAddress, expiration_seconds: int) -> str:
|
|
266
|
+
"""Create a presigned URL for downloading an object from GCS."""
|
|
267
|
+
if not soa.has_object:
|
|
268
|
+
raise excs.Error(f'StorageObjectAddress does not contain an object name: {soa}')
|
|
269
|
+
|
|
270
|
+
gcs_client = self.client()
|
|
271
|
+
bucket = gcs_client.bucket(soa.container)
|
|
272
|
+
blob = bucket.blob(soa.key)
|
|
273
|
+
|
|
274
|
+
presigned_url = blob.generate_signed_url(version='v4', expiration=expiration_seconds, method='GET')
|
|
275
|
+
return presigned_url
|
|
276
|
+
|
|
277
|
+
@classmethod
|
|
278
|
+
def handle_gcs_error(cls, e: Exception, bucket_name: str, operation: str = '', *, ignore_404: bool = False) -> None:
|
|
279
|
+
"""Handle GCS-specific errors and convert them to appropriate exceptions"""
|
|
280
|
+
if isinstance(e, NotFound):
|
|
281
|
+
if ignore_404:
|
|
282
|
+
return
|
|
283
|
+
raise excs.Error(f'Bucket or object {bucket_name} not found during {operation}: {str(e)!r}')
|
|
284
|
+
elif isinstance(e, Forbidden):
|
|
285
|
+
raise excs.Error(f'Access denied to bucket {bucket_name} during {operation}: {str(e)!r}')
|
|
286
|
+
elif isinstance(e, GoogleAPIError):
|
|
287
|
+
# Handle other Google API errors
|
|
288
|
+
error_message = str(e)
|
|
289
|
+
if 'Precondition' in error_message:
|
|
290
|
+
raise excs.Error(f'Precondition failed for bucket {bucket_name} during {operation}: {error_message}')
|
|
291
|
+
else:
|
|
292
|
+
raise excs.Error(f'Error during {operation} in bucket {bucket_name}: {error_message}')
|
|
293
|
+
else:
|
|
294
|
+
# Generic error handling
|
|
295
|
+
raise excs.Error(f'Unexpected error during {operation} in bucket {bucket_name}: {str(e)!r}')
|
pixeltable/utils/http.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import time
|
|
3
|
+
from http import HTTPStatus
|
|
4
|
+
from random import random
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
_RETRIABLE_ERROR_INDICATORS = (
|
|
8
|
+
'rate limit',
|
|
9
|
+
'too many requests',
|
|
10
|
+
'429',
|
|
11
|
+
'quota exceeded',
|
|
12
|
+
'throttled',
|
|
13
|
+
'rate exceeded',
|
|
14
|
+
'connection error',
|
|
15
|
+
'timed out',
|
|
16
|
+
)
|
|
17
|
+
_RETRY_AFTER_PATTERNS = (
|
|
18
|
+
r'retry after (\d+(?:\.\d+)?)\s*seconds?',
|
|
19
|
+
r'try again in (\d+(?:\.\d+)?)\s*seconds?',
|
|
20
|
+
r'wait (\d+(?:\.\d+)?)\s*seconds?',
|
|
21
|
+
r'retry-after:\s*(\d+(?:\.\d+)?)',
|
|
22
|
+
)
|
|
23
|
+
_RETRIABLE_HTTP_STATUSES: dict[str, int] = {
|
|
24
|
+
'TOO_MANY_REQUESTS': HTTPStatus.TOO_MANY_REQUESTS.value,
|
|
25
|
+
'SERVICE_UNAVAILABLE': HTTPStatus.SERVICE_UNAVAILABLE.value,
|
|
26
|
+
'REQUEST_TIMEOUT': HTTPStatus.REQUEST_TIMEOUT.value,
|
|
27
|
+
'GATEWAY_TIMEOUT': HTTPStatus.GATEWAY_TIMEOUT.value,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_retriable_error(exc: Exception) -> tuple[bool, float | None]:
|
|
32
|
+
"""Attempts to guess if the exception indicates a retriable eror. If that is the case, returns True
|
|
33
|
+
and the retry delay in seconds."""
|
|
34
|
+
|
|
35
|
+
# Check for HTTP status TOO_MANY_REQUESTS in various exception classes.
|
|
36
|
+
# We look for attributes that contain status codes, instead of checking the type of the exception,
|
|
37
|
+
# in order to handle a wider variety of exception classes.
|
|
38
|
+
err_md = _extract_error_metadata(exc)
|
|
39
|
+
if (err_md is None or not err_md[0]) and hasattr(exc, 'response'):
|
|
40
|
+
err_md = _extract_error_metadata(exc.response)
|
|
41
|
+
|
|
42
|
+
if err_md is not None and err_md[0]:
|
|
43
|
+
retry_after = err_md[1]
|
|
44
|
+
return err_md[0], retry_after if retry_after is not None and retry_after >= 0 else None
|
|
45
|
+
|
|
46
|
+
# Check common rate limit keywords in exception message
|
|
47
|
+
error_msg = str(exc).lower()
|
|
48
|
+
if any(indicator in error_msg for indicator in _RETRIABLE_ERROR_INDICATORS):
|
|
49
|
+
retry_delay = _extract_retry_delay_from_message(error_msg)
|
|
50
|
+
return True, retry_delay if retry_delay is not None and retry_delay >= 0 else None
|
|
51
|
+
|
|
52
|
+
return False, None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _extract_error_metadata(obj: Any) -> tuple[bool, float | None] | None:
|
|
56
|
+
is_retriable: bool | None = None
|
|
57
|
+
retry_delay: float | None = None
|
|
58
|
+
for attr in ['status', 'code', 'status_code']:
|
|
59
|
+
if hasattr(obj, attr):
|
|
60
|
+
is_retriable = getattr(obj, attr) in _RETRIABLE_HTTP_STATUSES.values()
|
|
61
|
+
is_retriable |= str(getattr(obj, attr)).upper() in _RETRIABLE_HTTP_STATUSES
|
|
62
|
+
|
|
63
|
+
if hasattr(obj, 'headers'):
|
|
64
|
+
retry_delay = _extract_retry_delay_from_headers(obj.headers)
|
|
65
|
+
if retry_delay is not None:
|
|
66
|
+
is_retriable = True
|
|
67
|
+
|
|
68
|
+
return (is_retriable, retry_delay) if is_retriable is not None else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_retry_delay_from_headers(headers: Any | None) -> float | None:
|
|
72
|
+
"""Extract retry delay from HTTP headers."""
|
|
73
|
+
if headers is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
# convert headers to dict-like object for consistent access
|
|
77
|
+
header_dict: dict
|
|
78
|
+
if hasattr(headers, 'get'):
|
|
79
|
+
header_dict = headers
|
|
80
|
+
else:
|
|
81
|
+
# headers are a list of tuples or other format
|
|
82
|
+
try:
|
|
83
|
+
header_dict = dict(headers)
|
|
84
|
+
except (TypeError, ValueError):
|
|
85
|
+
return None
|
|
86
|
+
# normalize dict keys: lowercase and remove dashes
|
|
87
|
+
header_dict = {k.lower().replace('-', ''): v for k, v in header_dict.items()}
|
|
88
|
+
|
|
89
|
+
# check Retry-After header
|
|
90
|
+
retry_after = header_dict.get('retryafter')
|
|
91
|
+
if retry_after is not None:
|
|
92
|
+
try:
|
|
93
|
+
return float(retry_after)
|
|
94
|
+
except (ValueError, TypeError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# check X-RateLimit-Reset (Unix timestamp)
|
|
98
|
+
reset_time = header_dict.get('xratelimitreset')
|
|
99
|
+
if reset_time is not None:
|
|
100
|
+
try:
|
|
101
|
+
reset_timestamp = float(reset_time)
|
|
102
|
+
delay = max(0, reset_timestamp - time.time())
|
|
103
|
+
return delay
|
|
104
|
+
except (ValueError, TypeError):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
# check X-RateLimit-Reset-After (seconds from now)
|
|
108
|
+
reset_after = header_dict.get('xratelimitresetafter')
|
|
109
|
+
if reset_after is not None:
|
|
110
|
+
try:
|
|
111
|
+
return float(reset_after)
|
|
112
|
+
except (ValueError, TypeError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _extract_retry_delay_from_message(msg: str) -> float | None:
|
|
119
|
+
msg_lower = msg.lower()
|
|
120
|
+
for pattern in _RETRY_AFTER_PATTERNS:
|
|
121
|
+
match = re.search(pattern, msg_lower)
|
|
122
|
+
if match is not None:
|
|
123
|
+
try:
|
|
124
|
+
return float(match.group(1))
|
|
125
|
+
except (ValueError, TypeError):
|
|
126
|
+
continue
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def exponential_backoff(attempt: int, base: float = 2.0, max_delay: float = 16.0) -> float:
|
|
131
|
+
"""Generates the retry delay using exponential backoff strategy with jitter. Attempt count starts from 0."""
|
|
132
|
+
basic_delay = min(max_delay, base**attempt) / 2
|
|
133
|
+
return basic_delay + random() * basic_delay
|
pixeltable/utils/http_server.py
CHANGED
|
@@ -2,14 +2,15 @@ import http
|
|
|
2
2
|
import http.server
|
|
3
3
|
import logging
|
|
4
4
|
import pathlib
|
|
5
|
-
import urllib
|
|
5
|
+
import urllib.request
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
_logger = logging.getLogger('pixeltable.http.server')
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def get_file_uri(http_address: str, file_path: str) -> str:
|
|
11
12
|
"""Get the URI for a file path, with the given prefix.
|
|
12
|
-
|
|
13
|
+
Used in the client to generate a URI
|
|
13
14
|
"""
|
|
14
15
|
abs_path = pathlib.Path(file_path)
|
|
15
16
|
assert abs_path.is_absolute()
|
|
@@ -19,25 +20,25 @@ def get_file_uri(http_address: str, file_path: str) -> str:
|
|
|
19
20
|
|
|
20
21
|
class AbsolutePathHandler(http.server.SimpleHTTPRequestHandler):
|
|
21
22
|
"""Serves all absolute paths, not just the current directory"""
|
|
23
|
+
|
|
22
24
|
def translate_path(self, path: str) -> str:
|
|
23
25
|
"""
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
Translate a /-separated PATH to the local filename syntax.
|
|
27
|
+
overrides http.server.SimpleHTTPRequestHandler.translate_path
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
This is only useful for file serving.
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
Code initially taken from there:
|
|
32
|
+
https://github.com/python/cpython/blob/f5406ef454662b98df107775d18ff71ae6849618/Lib/http/server.py#L834
|
|
31
33
|
"""
|
|
32
34
|
_logger.info(f'translate path {path=}')
|
|
33
35
|
# abandon query parameters, taken from http.server.SimpleHTTPRequestHandler
|
|
34
36
|
path = path.split('?', 1)[0]
|
|
35
37
|
path = path.split('#', 1)[0]
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
return str(path)
|
|
39
|
+
return str(pathlib.Path(urllib.request.url2pathname(path)))
|
|
39
40
|
|
|
40
|
-
def log_message(self, format, *args) -> None:
|
|
41
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
41
42
|
"""override logging to stderr in http.server.BaseHTTPRequestHandler"""
|
|
42
43
|
message = format % args
|
|
43
44
|
_logger.info(message.translate(self._control_char_table)) # type: ignore[attr-defined]
|
|
@@ -46,7 +47,7 @@ class AbsolutePathHandler(http.server.SimpleHTTPRequestHandler):
|
|
|
46
47
|
class LoggingHTTPServer(http.server.ThreadingHTTPServer):
|
|
47
48
|
"""Avoids polluting stdout and stderr"""
|
|
48
49
|
|
|
49
|
-
def handle_error(self, request, client_address) -> None:
|
|
50
|
+
def handle_error(self, request, client_address) -> None: # type: ignore[no-untyped-def]
|
|
50
51
|
"""override socketserver.TCPServer.handle_error which prints directly to sys.stderr"""
|
|
51
52
|
import traceback
|
|
52
53
|
|
|
@@ -57,11 +58,11 @@ class LoggingHTTPServer(http.server.ThreadingHTTPServer):
|
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
def make_server(address: str, port: int) -> http.server.HTTPServer:
|
|
60
|
-
"""Create a file server with pixeltable specific config
|
|
61
|
+
"""Create a file server with pixeltable specific config"""
|
|
61
62
|
return LoggingHTTPServer((address, port), AbsolutePathHandler)
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
if __name__ == '__main__':
|
|
65
66
|
httpd = make_server('127.0.0.1', 8000)
|
|
66
67
|
print(f'about to server HTTP on {httpd.server_address}')
|
|
67
|
-
httpd.serve_forever()
|
|
68
|
+
httpd.serve_forever()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pyiceberg.catalog.sql import SqlCatalog
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sqlite_catalog(warehouse_path: str | Path, name: str = 'pixeltable') -> SqlCatalog:
|
|
7
|
+
"""
|
|
8
|
+
Instantiate a sqlite Iceberg catalog at the specified path. If no catalog exists, one will be created.
|
|
9
|
+
"""
|
|
10
|
+
if isinstance(warehouse_path, str):
|
|
11
|
+
warehouse_path = Path(warehouse_path)
|
|
12
|
+
warehouse_path.mkdir(exist_ok=True)
|
|
13
|
+
return SqlCatalog(name, uri=f'sqlite:///{warehouse_path}/catalog.db', warehouse=f'file://{warehouse_path}')
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from io import BytesIO
|
|
3
|
+
|
|
4
|
+
import PIL.Image
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_format(img: PIL.Image.Image) -> str:
|
|
8
|
+
# Default to JPEG unless the image has a transparency layer (which isn't supported by JPEG).
|
|
9
|
+
# In that case, use WebP instead.
|
|
10
|
+
return 'webp' if img.has_transparency_data else 'jpeg'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def to_base64(image: PIL.Image.Image, format: str | None = None) -> str:
|
|
14
|
+
buffer = BytesIO()
|
|
15
|
+
image.save(buffer, format=format or image.format)
|
|
16
|
+
image_bytes = buffer.getvalue()
|
|
17
|
+
return base64.b64encode(image_bytes).decode('utf-8')
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import pixeltable as pxt
|
|
9
|
+
import pixeltable.exceptions as excs
|
|
10
|
+
from pixeltable.catalog import Catalog
|
|
11
|
+
from pixeltable.env import Env
|
|
12
|
+
|
|
13
|
+
_logger = logging.getLogger('pixeltable')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def export_lancedb(
|
|
17
|
+
table_or_query: pxt.Table | pxt.Query,
|
|
18
|
+
db_uri: Path,
|
|
19
|
+
table_name: str,
|
|
20
|
+
batch_size_bytes: int = 128 * 2**20,
|
|
21
|
+
if_exists: Literal['error', 'overwrite', 'append'] = 'error',
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Exports a Query's data to a LanceDB table.
|
|
25
|
+
|
|
26
|
+
This utilizes LanceDB's streaming interface for efficient table creation, via a sequence of in-memory pyarrow
|
|
27
|
+
`RecordBatches`, the size of which can be controlled with the `batch_size_bytes` parameter.
|
|
28
|
+
|
|
29
|
+
__Requirements:__
|
|
30
|
+
|
|
31
|
+
- `pip install lancedb`
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
table_or_query : Table or Query to export.
|
|
35
|
+
db_uri: Local Path to the LanceDB database.
|
|
36
|
+
table_name : Name of the table in the LanceDB database.
|
|
37
|
+
batch_size_bytes : Maximum size in bytes for each batch.
|
|
38
|
+
if_exists: Determines the behavior if the table already exists. Must be one of the following:
|
|
39
|
+
|
|
40
|
+
- `'error'`: raise an error
|
|
41
|
+
- `'overwrite'`: overwrite the existing table
|
|
42
|
+
- `'append'`: append to the existing table
|
|
43
|
+
"""
|
|
44
|
+
Env.get().require_package('lancedb')
|
|
45
|
+
|
|
46
|
+
import lancedb # type: ignore[import-untyped]
|
|
47
|
+
|
|
48
|
+
from pixeltable.utils.arrow import to_arrow_schema, to_record_batches
|
|
49
|
+
|
|
50
|
+
if if_exists not in ('error', 'overwrite', 'append'):
|
|
51
|
+
raise excs.Error("export_lancedb(): 'if_exists' must be one of: ['error', 'overwrite', 'append']")
|
|
52
|
+
|
|
53
|
+
query: pxt.Query
|
|
54
|
+
if isinstance(table_or_query, pxt.catalog.Table):
|
|
55
|
+
query = table_or_query.select()
|
|
56
|
+
else:
|
|
57
|
+
query = table_or_query
|
|
58
|
+
|
|
59
|
+
db_exists = False
|
|
60
|
+
if db_uri.exists():
|
|
61
|
+
if not db_uri.is_dir():
|
|
62
|
+
raise excs.Error(f"export_lancedb(): '{db_uri!s}' exists and is not a directory")
|
|
63
|
+
db_exists = True
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
db = lancedb.connect(str(db_uri))
|
|
67
|
+
lance_tbl: lancedb.LanceTable | None = None
|
|
68
|
+
try:
|
|
69
|
+
lance_tbl = db.open_table(table_name)
|
|
70
|
+
if if_exists == 'error':
|
|
71
|
+
raise excs.Error(f'export_lancedb(): table {table_name!r} already exists in {db_uri!r}')
|
|
72
|
+
except ValueError:
|
|
73
|
+
# table doesn't exist
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
with Catalog.get().begin_xact(for_write=False):
|
|
77
|
+
if lance_tbl is None or if_exists == 'overwrite':
|
|
78
|
+
mode = 'overwrite' if lance_tbl is not None else 'create'
|
|
79
|
+
arrow_schema = to_arrow_schema(query.schema)
|
|
80
|
+
_ = db.create_table(
|
|
81
|
+
table_name, to_record_batches(query, batch_size_bytes), schema=arrow_schema, mode=mode
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
lance_tbl.add(to_record_batches(query, batch_size_bytes))
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# cleanup
|
|
88
|
+
if not db_exists:
|
|
89
|
+
shutil.rmtree(db_uri)
|
|
90
|
+
raise e
|