zbq 0.0.1__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.
- zbq/__init__.py +24 -0
- zbq/base.py +139 -0
- zbq/main.py +394 -0
- zbq/storage.py +645 -0
- zbq-0.0.1.dist-info/METADATA +528 -0
- zbq-0.0.1.dist-info/RECORD +8 -0
- zbq-0.0.1.dist-info/WHEEL +4 -0
- zbq-0.0.1.dist-info/entry_points.txt +2 -0
zbq/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .main import BigQueryHandler
|
|
2
|
+
from .storage import StorageHandler, UploadResult, DownloadResult
|
|
3
|
+
from .base import (
|
|
4
|
+
ZbqError,
|
|
5
|
+
ZbqAuthenticationError,
|
|
6
|
+
ZbqConfigurationError,
|
|
7
|
+
ZbqOperationError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
zclient = BigQueryHandler()
|
|
11
|
+
zstorage = StorageHandler()
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"zclient",
|
|
15
|
+
"zstorage",
|
|
16
|
+
"BigQueryHandler",
|
|
17
|
+
"StorageHandler",
|
|
18
|
+
"UploadResult",
|
|
19
|
+
"DownloadResult",
|
|
20
|
+
"ZbqError",
|
|
21
|
+
"ZbqAuthenticationError",
|
|
22
|
+
"ZbqConfigurationError",
|
|
23
|
+
"ZbqOperationError",
|
|
24
|
+
]
|
zbq/base.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from google.auth import default as get_google_credentials
|
|
4
|
+
from google.auth.exceptions import DefaultCredentialsError
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
# Custom exceptions
|
|
10
|
+
class ZbqError(Exception):
|
|
11
|
+
"""Base exception for zbq package"""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ZbqAuthenticationError(ZbqError):
|
|
17
|
+
"""Authentication related errors"""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ZbqConfigurationError(ZbqError):
|
|
23
|
+
"""Configuration related errors"""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ZbqOperationError(ZbqError):
|
|
29
|
+
"""Operation related errors"""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
class BaseClientManager:
|
|
34
|
+
def __init__(self, project_id: str = "", log_level: str = "INFO"):
|
|
35
|
+
self._project_id = project_id.strip() or self._get_default_project()
|
|
36
|
+
self.logger = logger
|
|
37
|
+
self._setup_logging(log_level)
|
|
38
|
+
self._client = None
|
|
39
|
+
|
|
40
|
+
def __enter__(self):
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
44
|
+
if self._client:
|
|
45
|
+
try:
|
|
46
|
+
self._client.close()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def _create_client(self):
|
|
52
|
+
raise NotImplementedError("Subclasses must implement _create_client()")
|
|
53
|
+
|
|
54
|
+
def _setup_logging(self, level: str = "INFO"):
|
|
55
|
+
"""Set up loguru logging for zbq operations"""
|
|
56
|
+
logger.remove() # Remove default handler
|
|
57
|
+
logger.add(
|
|
58
|
+
lambda msg: print(msg, end=""),
|
|
59
|
+
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>zbq</cyan> | <level>{message}</level>",
|
|
60
|
+
level=level.upper()
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@contextmanager
|
|
65
|
+
def _fresh_client(self):
|
|
66
|
+
"""Context manager that provides a fresh client for each operation.
|
|
67
|
+
|
|
68
|
+
This eliminates shared client state issues by creating a new client
|
|
69
|
+
for each operation and ensuring proper cleanup.
|
|
70
|
+
"""
|
|
71
|
+
temp_client = None
|
|
72
|
+
try:
|
|
73
|
+
if not self._check_adc():
|
|
74
|
+
raise ZbqAuthenticationError(
|
|
75
|
+
"No Google Cloud credentials found. Run:\n"
|
|
76
|
+
" gcloud auth application-default login\n"
|
|
77
|
+
"Or set the GOOGLE_APPLICATION_CREDENTIALS environment variable."
|
|
78
|
+
)
|
|
79
|
+
if not self.project_id:
|
|
80
|
+
raise ZbqConfigurationError(
|
|
81
|
+
"No GCP project found. Set one via:\n"
|
|
82
|
+
" gcloud config set project YOUR_PROJECT_ID\n"
|
|
83
|
+
"Or set manually: zclient.project_id = 'your-project'"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
temp_client = self._create_client()
|
|
87
|
+
yield temp_client
|
|
88
|
+
|
|
89
|
+
finally:
|
|
90
|
+
if temp_client:
|
|
91
|
+
try:
|
|
92
|
+
temp_client.close()
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # Ignore cleanup errors
|
|
95
|
+
|
|
96
|
+
def _get_default_project(self):
|
|
97
|
+
config_path = os.path.expanduser(
|
|
98
|
+
"~/.config/gcloud/configurations/config_default"
|
|
99
|
+
)
|
|
100
|
+
if os.name == "nt": # Windows
|
|
101
|
+
config_path = os.path.expandvars(
|
|
102
|
+
r"%APPDATA%\gcloud\configurations\config_default"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
parser = configparser.ConfigParser()
|
|
106
|
+
try:
|
|
107
|
+
parser.read(config_path)
|
|
108
|
+
project = parser.get("core", "project", fallback="")
|
|
109
|
+
return project.strip()
|
|
110
|
+
except Exception:
|
|
111
|
+
return os.environ.get("GOOGLE_CLOUD_PROJECT", "").strip()
|
|
112
|
+
|
|
113
|
+
# Fallback to environment
|
|
114
|
+
return os.environ.get("GOOGLE_CLOUD_PROJECT", "").strip()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def client(self):
|
|
118
|
+
if self._client is None:
|
|
119
|
+
self._init_client()
|
|
120
|
+
return self._client
|
|
121
|
+
|
|
122
|
+
def _check_adc(self) -> bool:
|
|
123
|
+
try:
|
|
124
|
+
creds, proj = get_google_credentials()
|
|
125
|
+
return True
|
|
126
|
+
except DefaultCredentialsError:
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def project_id(self):
|
|
131
|
+
return self._project_id
|
|
132
|
+
|
|
133
|
+
@project_id.setter
|
|
134
|
+
def project_id(self, id: str):
|
|
135
|
+
if not isinstance(id, str):
|
|
136
|
+
raise ValueError("Project ID must be a string")
|
|
137
|
+
if id != self._project_id:
|
|
138
|
+
self._project_id = id
|
|
139
|
+
|
zbq/main.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
from polars.exceptions import PolarsError
|
|
2
|
+
from google.cloud import bigquery
|
|
3
|
+
from zbq.base import BaseClientManager, ZbqAuthenticationError, ZbqOperationError
|
|
4
|
+
import polars as pl
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
import os
|
|
8
|
+
from typing import Dict, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BigQueryHandler(BaseClientManager):
|
|
12
|
+
"""Enhanced Google BigQuery handler with improved error handling and logging"""
|
|
13
|
+
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
project_id: str = "",
|
|
17
|
+
default_timeout: int = 300,
|
|
18
|
+
log_level: str = "INFO",
|
|
19
|
+
):
|
|
20
|
+
super().__init__(project_id, log_level)
|
|
21
|
+
self.default_timeout = default_timeout
|
|
22
|
+
|
|
23
|
+
def _create_client(self):
|
|
24
|
+
return bigquery.Client(project=self.project_id)
|
|
25
|
+
|
|
26
|
+
def _substitute_parameters(
|
|
27
|
+
self, query: str, parameters: Dict[str, Any] | None = None
|
|
28
|
+
) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Substitute parameters in a SQL query using @param_name syntax.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
query: SQL query string with @param_name placeholders
|
|
34
|
+
parameters: Dictionary mapping parameter names to values
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Query string with parameters substituted
|
|
38
|
+
|
|
39
|
+
Notes:
|
|
40
|
+
- Regular parameters are properly quoted/escaped based on their data type
|
|
41
|
+
- List/tuple parameters are formatted as comma-separated values (item1, item2, ...)
|
|
42
|
+
- Parameters named "condition" are inserted as raw SQL without quotes
|
|
43
|
+
- Missing "condition" parameters are removed entirely (including surrounding whitespace)
|
|
44
|
+
- Missing non-condition parameters raise ZbqOperationError
|
|
45
|
+
- Empty lists/tuples are converted to NULL
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ZbqOperationError: If parameter substitution fails or required parameters are missing
|
|
49
|
+
"""
|
|
50
|
+
if not parameters:
|
|
51
|
+
return query
|
|
52
|
+
|
|
53
|
+
# Validate parameters dict
|
|
54
|
+
if not isinstance(parameters, dict):
|
|
55
|
+
raise ZbqOperationError("Parameters must be a dictionary")
|
|
56
|
+
|
|
57
|
+
# Validate parameter names (only alphanumeric and underscore allowed)
|
|
58
|
+
for param_name in parameters.keys():
|
|
59
|
+
if not isinstance(param_name, str) or not re.match(r"^\w+$", param_name):
|
|
60
|
+
raise ZbqOperationError(
|
|
61
|
+
f"Invalid parameter name: '{param_name}'. Parameter names must contain only letters, numbers, and underscores."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
# Find all @param_name patterns in the query
|
|
66
|
+
param_pattern = r"@(\w+)"
|
|
67
|
+
found_params = re.findall(param_pattern, query)
|
|
68
|
+
|
|
69
|
+
# Separate condition parameters from required parameters
|
|
70
|
+
missing_params = []
|
|
71
|
+
condition_params = []
|
|
72
|
+
|
|
73
|
+
for param in found_params:
|
|
74
|
+
if param not in parameters:
|
|
75
|
+
if param == "condition":
|
|
76
|
+
condition_params.append(param)
|
|
77
|
+
else:
|
|
78
|
+
missing_params.append(param)
|
|
79
|
+
|
|
80
|
+
# Raise error only for missing non-condition parameters
|
|
81
|
+
if missing_params:
|
|
82
|
+
raise ZbqOperationError(
|
|
83
|
+
f"Missing values for parameters: {', '.join(missing_params)}"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Substitute parameters
|
|
87
|
+
result_query = query
|
|
88
|
+
for param_name in found_params:
|
|
89
|
+
if param_name in parameters:
|
|
90
|
+
# Parameter has a value - substitute it
|
|
91
|
+
param_value = parameters[param_name]
|
|
92
|
+
|
|
93
|
+
# Handle condition parameters as raw SQL
|
|
94
|
+
if param_name == "condition":
|
|
95
|
+
# Condition parameters are inserted as raw SQL without quotes
|
|
96
|
+
substituted_value = str(param_value)
|
|
97
|
+
else:
|
|
98
|
+
# Handle different parameter types for regular parameters
|
|
99
|
+
if isinstance(param_value, str):
|
|
100
|
+
# For string values, check if it looks like a table/dataset identifier
|
|
101
|
+
if "." in param_value and not param_value.startswith("`"):
|
|
102
|
+
# Likely a table identifier, add backticks
|
|
103
|
+
substituted_value = f"`{param_value}`"
|
|
104
|
+
else:
|
|
105
|
+
# Regular string value, add quotes
|
|
106
|
+
escaped_value = param_value.replace("'", "''")
|
|
107
|
+
substituted_value = f"'{escaped_value}'"
|
|
108
|
+
elif isinstance(param_value, (int, float)):
|
|
109
|
+
# Numeric values don't need quotes
|
|
110
|
+
substituted_value = str(param_value)
|
|
111
|
+
elif param_value is None:
|
|
112
|
+
substituted_value = "NULL"
|
|
113
|
+
elif isinstance(param_value, bool):
|
|
114
|
+
substituted_value = "TRUE" if param_value else "FALSE"
|
|
115
|
+
elif isinstance(param_value, (list, tuple)):
|
|
116
|
+
# Handle lists and tuples - format as comma-separated values
|
|
117
|
+
if not param_value:
|
|
118
|
+
# Empty list/tuple - use NULL
|
|
119
|
+
substituted_value = "NULL"
|
|
120
|
+
else:
|
|
121
|
+
# Process each element in the list
|
|
122
|
+
formatted_items = []
|
|
123
|
+
for item in param_value:
|
|
124
|
+
if isinstance(item, str):
|
|
125
|
+
# String items get quoted and escaped
|
|
126
|
+
escaped_item = item.replace("'", "''")
|
|
127
|
+
formatted_items.append(f"'{escaped_item}'")
|
|
128
|
+
elif isinstance(item, (int, float)):
|
|
129
|
+
# Numeric items don't need quotes
|
|
130
|
+
formatted_items.append(str(item))
|
|
131
|
+
elif item is None:
|
|
132
|
+
formatted_items.append("NULL")
|
|
133
|
+
elif isinstance(item, bool):
|
|
134
|
+
formatted_items.append("TRUE" if item else "FALSE")
|
|
135
|
+
else:
|
|
136
|
+
# Other types converted to string and quoted
|
|
137
|
+
escaped_item = str(item).replace("'", "''")
|
|
138
|
+
formatted_items.append(f"'{escaped_item}'")
|
|
139
|
+
|
|
140
|
+
# Join items with commas (no automatic wrapping)
|
|
141
|
+
substituted_value = ', '.join(formatted_items)
|
|
142
|
+
else:
|
|
143
|
+
# For other types, convert to string and quote
|
|
144
|
+
escaped_value = str(param_value).replace("'", "''")
|
|
145
|
+
substituted_value = f"'{escaped_value}'"
|
|
146
|
+
|
|
147
|
+
# Replace all occurrences of @param_name
|
|
148
|
+
result_query = result_query.replace(f"@{param_name}", substituted_value)
|
|
149
|
+
elif param_name == "condition":
|
|
150
|
+
# Missing condition parameter - remove it entirely with conservative whitespace handling
|
|
151
|
+
# Only remove spaces/tabs immediately around @condition, preserve newlines to avoid joining SQL keywords
|
|
152
|
+
condition_pattern = rf"[ \t]*@{param_name}[ \t]*"
|
|
153
|
+
result_query = re.sub(condition_pattern, "", result_query)
|
|
154
|
+
|
|
155
|
+
return result_query
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
if isinstance(e, ZbqOperationError):
|
|
159
|
+
raise
|
|
160
|
+
raise ZbqOperationError(f"Parameter substitution failed: {str(e)}")
|
|
161
|
+
|
|
162
|
+
def validate(self):
|
|
163
|
+
"""Optional helper: raise if ADC or project_id not set"""
|
|
164
|
+
if not self._check_adc():
|
|
165
|
+
raise RuntimeError(
|
|
166
|
+
"Missing ADC. Run: gcloud auth application-default login"
|
|
167
|
+
)
|
|
168
|
+
if not self.project_id:
|
|
169
|
+
raise RuntimeError("Project ID not set.")
|
|
170
|
+
|
|
171
|
+
def read(
|
|
172
|
+
self,
|
|
173
|
+
query: str | None = None,
|
|
174
|
+
timeout: int | None = None,
|
|
175
|
+
parameters: Dict[str, Any] | None = None,
|
|
176
|
+
dry_run: bool = False,
|
|
177
|
+
):
|
|
178
|
+
"""
|
|
179
|
+
Execute a SQL query and return results as a Polars DataFrame.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
query (str): SQL query string. Can include @param_name placeholders.
|
|
183
|
+
timeout (int, optional): Query timeout in seconds. Uses default_timeout if not specified.
|
|
184
|
+
parameters (Dict[str, Any], optional): Parameters for @param_name substitution in query.
|
|
185
|
+
dry_run (bool, optional): If True, prints the final query after parameter substitution without executing it.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
pl.DataFrame: Query results as a Polars DataFrame, or None if dry_run is True.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ValueError: If query is empty.
|
|
192
|
+
ZbqOperationError: If parameter substitution fails.
|
|
193
|
+
TimeoutError: If query times out.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
if query:
|
|
197
|
+
try:
|
|
198
|
+
return self._query(query, timeout, parameters, dry_run)
|
|
199
|
+
except TimeoutError as e:
|
|
200
|
+
print(f"Read operation timed out: {e}")
|
|
201
|
+
raise
|
|
202
|
+
except Exception as e:
|
|
203
|
+
print(f"Read operation failed: {e}")
|
|
204
|
+
raise
|
|
205
|
+
else:
|
|
206
|
+
raise ValueError("Query is empty.")
|
|
207
|
+
|
|
208
|
+
def insert(
|
|
209
|
+
self,
|
|
210
|
+
query: str,
|
|
211
|
+
timeout: int | None = None,
|
|
212
|
+
parameters: Dict[str, Any] | None = None,
|
|
213
|
+
dry_run: bool = False,
|
|
214
|
+
):
|
|
215
|
+
return self.read(query, timeout, parameters, dry_run)
|
|
216
|
+
|
|
217
|
+
def update(
|
|
218
|
+
self,
|
|
219
|
+
query: str,
|
|
220
|
+
timeout: int | None = None,
|
|
221
|
+
parameters: Dict[str, Any] | None = None,
|
|
222
|
+
dry_run: bool = False,
|
|
223
|
+
):
|
|
224
|
+
return self.read(query, timeout, parameters, dry_run)
|
|
225
|
+
|
|
226
|
+
def delete(
|
|
227
|
+
self,
|
|
228
|
+
query: str,
|
|
229
|
+
timeout: int | None = None,
|
|
230
|
+
parameters: Dict[str, Any] | None = None,
|
|
231
|
+
dry_run: bool = False,
|
|
232
|
+
):
|
|
233
|
+
return self.read(query, timeout, parameters, dry_run)
|
|
234
|
+
|
|
235
|
+
def write(
|
|
236
|
+
self,
|
|
237
|
+
df: pl.DataFrame,
|
|
238
|
+
full_table_path: str,
|
|
239
|
+
write_type: str = "append",
|
|
240
|
+
warning: bool = True,
|
|
241
|
+
create_if_needed: bool = True,
|
|
242
|
+
timeout: int | None = None,
|
|
243
|
+
):
|
|
244
|
+
self._check_requirements(df, full_table_path)
|
|
245
|
+
return self._write(
|
|
246
|
+
df, full_table_path, write_type, warning, create_if_needed, timeout
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _check_requirements(self, df, full_table_path):
|
|
250
|
+
if df.is_empty() or not full_table_path:
|
|
251
|
+
missing = []
|
|
252
|
+
if df.is_empty():
|
|
253
|
+
missing.append("df")
|
|
254
|
+
if not full_table_path:
|
|
255
|
+
missing.append("full_table_path")
|
|
256
|
+
raise ValueError(f"Missing required argument(s): {', '.join(missing)}")
|
|
257
|
+
|
|
258
|
+
def _query(
|
|
259
|
+
self,
|
|
260
|
+
query: str,
|
|
261
|
+
timeout: int | None = None,
|
|
262
|
+
parameters: Dict[str, Any] | None = None,
|
|
263
|
+
dry_run: bool = False,
|
|
264
|
+
) -> pl.DataFrame | pl.Series | None:
|
|
265
|
+
timeout = timeout or self.default_timeout
|
|
266
|
+
|
|
267
|
+
# Substitute parameters if provided
|
|
268
|
+
if parameters:
|
|
269
|
+
query = self._substitute_parameters(query, parameters)
|
|
270
|
+
|
|
271
|
+
# If dry_run is enabled, print the final query and return None
|
|
272
|
+
if dry_run:
|
|
273
|
+
print("DRY RUN - Query that would be executed:")
|
|
274
|
+
print("-" * 50)
|
|
275
|
+
print(query)
|
|
276
|
+
print("-" * 50)
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
# Use fresh client for each query to eliminate shared state issues
|
|
281
|
+
with self._fresh_client() as client:
|
|
282
|
+
query_job = client.query(query)
|
|
283
|
+
|
|
284
|
+
if re.search(r"\b(insert|update|delete)\b", query, re.IGNORECASE):
|
|
285
|
+
try:
|
|
286
|
+
query_job.result(timeout=timeout)
|
|
287
|
+
return pl.DataFrame(
|
|
288
|
+
{"status": ["OK"], "job_id": [query_job.job_id]}
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
if "timeout" in str(e).lower():
|
|
292
|
+
raise TimeoutError(
|
|
293
|
+
f"Query timed out after {timeout} seconds"
|
|
294
|
+
)
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
rows = query_job.result(timeout=timeout).to_arrow(
|
|
299
|
+
progress_bar_type=None
|
|
300
|
+
)
|
|
301
|
+
df = pl.from_arrow(rows)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
if "timeout" in str(e).lower():
|
|
304
|
+
raise TimeoutError(f"Query timed out after {timeout} seconds")
|
|
305
|
+
raise
|
|
306
|
+
|
|
307
|
+
except PolarsError as e:
|
|
308
|
+
print(f"PanicException: {e}")
|
|
309
|
+
print("Retrying with Pandas DF")
|
|
310
|
+
try:
|
|
311
|
+
with self._fresh_client() as client:
|
|
312
|
+
query_job = client.query(query)
|
|
313
|
+
pandas_df = query_job.result(timeout=timeout).to_dataframe(
|
|
314
|
+
progress_bar_type=None
|
|
315
|
+
)
|
|
316
|
+
df = pl.from_pandas(pandas_df)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
if "timeout" in str(e).lower():
|
|
319
|
+
raise TimeoutError(f"Query timed out after {timeout} seconds")
|
|
320
|
+
raise
|
|
321
|
+
|
|
322
|
+
return df
|
|
323
|
+
|
|
324
|
+
def _write(
|
|
325
|
+
self,
|
|
326
|
+
df: pl.DataFrame,
|
|
327
|
+
full_table_path: str,
|
|
328
|
+
write_type: str = "append",
|
|
329
|
+
warning: bool = True,
|
|
330
|
+
create_if_needed: bool = True,
|
|
331
|
+
timeout: int | None = None,
|
|
332
|
+
):
|
|
333
|
+
timeout = timeout or self.default_timeout
|
|
334
|
+
destination = full_table_path
|
|
335
|
+
temp_file = tempfile.NamedTemporaryFile(suffix=".parquet", delete=False)
|
|
336
|
+
temp_file_path = temp_file.name
|
|
337
|
+
temp_file.close()
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
df.write_parquet(temp_file_path)
|
|
341
|
+
|
|
342
|
+
if write_type == "truncate" and warning:
|
|
343
|
+
try:
|
|
344
|
+
user_warning = input(
|
|
345
|
+
"You are about to overwrite a table. Continue? (y/n): "
|
|
346
|
+
)
|
|
347
|
+
if user_warning.lower() != "y":
|
|
348
|
+
return "CANCELLED"
|
|
349
|
+
except (EOFError, KeyboardInterrupt):
|
|
350
|
+
print("\nOperation cancelled by user")
|
|
351
|
+
return "CANCELLED"
|
|
352
|
+
|
|
353
|
+
write_disp = (
|
|
354
|
+
bigquery.WriteDisposition.WRITE_TRUNCATE
|
|
355
|
+
if write_type == "truncate"
|
|
356
|
+
else bigquery.WriteDisposition.WRITE_APPEND
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
create_disp = (
|
|
360
|
+
bigquery.CreateDisposition.CREATE_IF_NEEDED
|
|
361
|
+
if create_if_needed
|
|
362
|
+
else bigquery.CreateDisposition.CREATE_NEVER
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Use fresh client for write operation to eliminate shared state issues
|
|
366
|
+
with self._fresh_client() as client:
|
|
367
|
+
with open(temp_file_path, "rb") as source_file:
|
|
368
|
+
job = client.load_table_from_file(
|
|
369
|
+
source_file,
|
|
370
|
+
destination=destination,
|
|
371
|
+
project=self.project_id,
|
|
372
|
+
job_config=bigquery.LoadJobConfig(
|
|
373
|
+
source_format=bigquery.SourceFormat.PARQUET,
|
|
374
|
+
write_disposition=write_disp,
|
|
375
|
+
create_disposition=create_disp,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
# Add timeout to prevent hanging on job.result()
|
|
379
|
+
try:
|
|
380
|
+
result = job.result(timeout=timeout)
|
|
381
|
+
return result.state
|
|
382
|
+
except Exception as e:
|
|
383
|
+
if "timeout" in str(e).lower():
|
|
384
|
+
raise TimeoutError(
|
|
385
|
+
f"Write operation timed out after {timeout} seconds"
|
|
386
|
+
)
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
finally:
|
|
390
|
+
if os.path.exists(temp_file_path):
|
|
391
|
+
try:
|
|
392
|
+
os.remove(temp_file_path)
|
|
393
|
+
except OSError:
|
|
394
|
+
pass # Ignore cleanup errors
|