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 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