sibi-dst 2025.8.5__py3-none-any.whl → 2025.8.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.
- sibi_dst/df_helper/_df_helper.py +40 -6
- sibi_dst/utils/async_utils.py +12 -0
- sibi_dst/utils/clickhouse_writer.py +4 -241
- sibi_dst/utils/storage_config.py +2 -2
- sibi_dst/utils/storage_hive.py +195 -0
- sibi_dst/utils/storage_manager.py +3 -2
- {sibi_dst-2025.8.5.dist-info → sibi_dst-2025.8.7.dist-info}/METADATA +1 -1
- {sibi_dst-2025.8.5.dist-info → sibi_dst-2025.8.7.dist-info}/RECORD +9 -7
- {sibi_dst-2025.8.5.dist-info → sibi_dst-2025.8.7.dist-info}/WHEEL +0 -0
sibi_dst/df_helper/_df_helper.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import asyncio
|
3
4
|
from typing import Any, Dict, Optional, TypeVar, Union
|
4
5
|
|
5
6
|
import dask.dataframe as dd
|
@@ -104,7 +105,6 @@ class HttpBackend(BaseBackend):
|
|
104
105
|
return self.total_records, result
|
105
106
|
|
106
107
|
|
107
|
-
# ---- Main DfHelper ----
|
108
108
|
class DfHelper(ManagedResource):
|
109
109
|
_BACKEND_STRATEGIES = {
|
110
110
|
"sqlalchemy": SqlAlchemyBackend,
|
@@ -198,6 +198,37 @@ class DfHelper(ManagedResource):
|
|
198
198
|
df = df.persist() if persist else df
|
199
199
|
return df.compute() if as_pandas else df
|
200
200
|
|
201
|
+
async def load_async(
|
202
|
+
self,
|
203
|
+
*,
|
204
|
+
persist: bool = False,
|
205
|
+
as_pandas: bool = False,
|
206
|
+
prefer_native: bool = False,
|
207
|
+
**options,
|
208
|
+
):
|
209
|
+
"""
|
210
|
+
Async load that prefers native async backends when available,
|
211
|
+
otherwise runs the sync `load()` in a worker thread via asyncio.to_thread.
|
212
|
+
|
213
|
+
Args:
|
214
|
+
persist: same as `load`
|
215
|
+
as_pandas: same as `load`
|
216
|
+
prefer_native: if True and the backend overrides `aload`, use it.
|
217
|
+
otherwise force thread offload of `load()`.
|
218
|
+
**options: forwarded to `load` / `aload`
|
219
|
+
"""
|
220
|
+
# If the backend provided an override for `aload`, use it
|
221
|
+
if prefer_native and type(self.backend_strategy).aload is not BaseBackend.aload:
|
222
|
+
return await self.aload(persist=persist, as_pandas=as_pandas, **options)
|
223
|
+
|
224
|
+
# Fall back to offloading the sync path to a thread
|
225
|
+
return await asyncio.to_thread(
|
226
|
+
self.load,
|
227
|
+
persist=persist,
|
228
|
+
as_pandas=as_pandas,
|
229
|
+
**options,
|
230
|
+
)
|
231
|
+
|
201
232
|
# ---------- dataframe post-processing ----------
|
202
233
|
def _post_process_df(self, df: dd.DataFrame) -> dd.DataFrame:
|
203
234
|
self.logger.debug("Post-processing DataFrame.")
|
@@ -240,9 +271,12 @@ class DfHelper(ManagedResource):
|
|
240
271
|
return df
|
241
272
|
|
242
273
|
# ---------- sinks ----------
|
243
|
-
def save_to_parquet(self, df: dd.DataFrame,
|
244
|
-
fs: AbstractFileSystem = kwargs.
|
245
|
-
path: str = kwargs.
|
274
|
+
def save_to_parquet(self, df: dd.DataFrame, **kwargs):
|
275
|
+
fs: AbstractFileSystem = kwargs.pop("fs", self.fs)
|
276
|
+
path: str = kwargs.pop("parquet_storage_path", self.backend_parquet.parquet_storage_path if self.backend_parquet else None)
|
277
|
+
parquet_filename = kwargs.pop("parquet_filename" or self._backend_params.parquet_filename if self.backend_parquet else None)
|
278
|
+
if not parquet_filename:
|
279
|
+
raise ValueError("A 'parquet_filename' keyword argument must be provided.")
|
246
280
|
if not fs:
|
247
281
|
raise ValueError("A filesystem (fs) must be provided to save the parquet file.")
|
248
282
|
if not path:
|
@@ -268,11 +302,11 @@ class DfHelper(ManagedResource):
|
|
268
302
|
if hasattr(df, "npartitions") and df.npartitions == 1 and not len(df.head(1)):
|
269
303
|
self.logger.warning("Cannot write to ClickHouse; DataFrame is empty.")
|
270
304
|
return
|
271
|
-
with ClickHouseWriter(debug=self.debug, logger=self.logger,
|
305
|
+
with ClickHouseWriter(debug=self.debug, logger=self.logger, verbose=self.verbose, **credentials) as writer:
|
272
306
|
writer.save_to_clickhouse(df)
|
273
307
|
self.logger.debug("Save to ClickHouse completed.")
|
274
308
|
|
275
|
-
# ----------
|
309
|
+
# ---------- period loaders ----------
|
276
310
|
def load_period(self, dt_field: str, start: str, end: str, **kwargs):
|
277
311
|
final_kwargs = self._prepare_period_filters(dt_field, start, end, **kwargs)
|
278
312
|
return self.load(**final_kwargs)
|
@@ -0,0 +1,12 @@
|
|
1
|
+
import asyncio
|
2
|
+
import dask.dataframe as dd
|
3
|
+
|
4
|
+
|
5
|
+
def is_dask_dataframe(df):
|
6
|
+
"""Check if the given object is a Dask DataFrame."""
|
7
|
+
return isinstance(df, dd.DataFrame)
|
8
|
+
|
9
|
+
async def to_thread(func, *args, **kwargs):
|
10
|
+
"""Explicit helper to keep code clear where we hop off the event loop."""
|
11
|
+
return await asyncio.to_thread(func, *args, **kwargs)
|
12
|
+
|
@@ -91,7 +91,7 @@ class ClickHouseWriter(ManagedResource):
|
|
91
91
|
return
|
92
92
|
|
93
93
|
# lazily fill missing values per-partition (no global compute)
|
94
|
-
df = df.map_partitions(self._fill_missing_partition, meta=df)
|
94
|
+
df = df.map_partitions(type(self)._fill_missing_partition, meta=df._meta)
|
95
95
|
|
96
96
|
# (re)create table
|
97
97
|
ow = self.overwrite if overwrite is None else bool(overwrite)
|
@@ -201,23 +201,21 @@ class ClickHouseWriter(ManagedResource):
|
|
201
201
|
|
202
202
|
# ------------- missing values (lazy) -------------
|
203
203
|
|
204
|
-
|
205
|
-
|
204
|
+
@staticmethod
|
205
|
+
def _fill_missing_partition(pdf: pd.DataFrame) -> pd.DataFrame:
|
206
|
+
# (unchanged body)
|
206
207
|
for col in pdf.columns:
|
207
208
|
s = pdf[col]
|
208
209
|
if pd.api.types.is_integer_dtype(s.dtype):
|
209
|
-
# pandas nullable IntX supports NA → fill where needed
|
210
210
|
if pd.api.types.is_extension_array_dtype(s.dtype):
|
211
211
|
pdf[col] = s.fillna(pd.NA)
|
212
212
|
else:
|
213
213
|
pdf[col] = s.fillna(0)
|
214
214
|
elif pd.api.types.is_bool_dtype(s.dtype):
|
215
|
-
# boolean pandas extension supports NA, ClickHouse uses UInt8; keep NA → Nullable
|
216
215
|
pdf[col] = s.fillna(pd.NA)
|
217
216
|
elif pd.api.types.is_float_dtype(s.dtype):
|
218
217
|
pdf[col] = s.fillna(0.0)
|
219
218
|
elif pd.api.types.is_datetime64_any_dtype(s.dtype):
|
220
|
-
# keep NaT; ClickHouse Nullable(DateTime) will take NULL
|
221
219
|
pass
|
222
220
|
else:
|
223
221
|
pdf[col] = s.fillna("")
|
@@ -264,238 +262,3 @@ class ClickHouseWriter(ManagedResource):
|
|
264
262
|
if hasattr(self._tlocal, "client"):
|
265
263
|
delattr(self._tlocal, "client")
|
266
264
|
|
267
|
-
# from concurrent.futures import ThreadPoolExecutor
|
268
|
-
# from typing import ClassVar, Dict
|
269
|
-
#
|
270
|
-
# import clickhouse_connect
|
271
|
-
# import pandas as pd
|
272
|
-
# from clickhouse_driver import Client
|
273
|
-
# import dask.dataframe as dd
|
274
|
-
#
|
275
|
-
# from . import ManagedResource
|
276
|
-
#
|
277
|
-
#
|
278
|
-
# class ClickHouseWriter(ManagedResource):
|
279
|
-
# """
|
280
|
-
# Provides functionality to write a Dask DataFrame to a ClickHouse database using
|
281
|
-
# a specified schema. This class handles the creation of tables, schema generation,
|
282
|
-
# data transformation, and data insertion. It ensures compatibility between Dask
|
283
|
-
# data types and ClickHouse types.
|
284
|
-
#
|
285
|
-
# :ivar clickhouse_host: Host address of the ClickHouse database.
|
286
|
-
# :type clickhouse_host: str
|
287
|
-
# :ivar clickhouse_port: Port of the ClickHouse database.
|
288
|
-
# :type clickhouse_port: int
|
289
|
-
# :ivar clickhouse_dbname: Name of the database to connect to in ClickHouse.
|
290
|
-
# :type clickhouse_dbname: str
|
291
|
-
# :ivar clickhouse_user: Username for database authentication.
|
292
|
-
# :type clickhouse_user: str
|
293
|
-
# :ivar clickhouse_password: Password for database authentication.
|
294
|
-
# :type clickhouse_password: str
|
295
|
-
# :ivar clickhouse_table: Name of the table to store the data in.
|
296
|
-
# :type clickhouse_table: str
|
297
|
-
# :ivar logger: Logger instance for logging messages.
|
298
|
-
# :type logger: logging.Logger
|
299
|
-
# :ivar client: Instance of the ClickHouse database client.
|
300
|
-
# :type client: clickhouse_connect.Client or None
|
301
|
-
# :ivar df: Dask DataFrame to be written into ClickHouse.
|
302
|
-
# :type df: dask.dataframe.DataFrame
|
303
|
-
# :ivar order_by: Field or column name to use for table ordering.
|
304
|
-
# :type order_by: str
|
305
|
-
# """
|
306
|
-
# dtype_to_clickhouse: ClassVar[Dict[str, str]] = {
|
307
|
-
# 'int64': 'Int64',
|
308
|
-
# 'int32': 'Int32',
|
309
|
-
# 'float64': 'Float64',
|
310
|
-
# 'float32': 'Float32',
|
311
|
-
# 'bool': 'UInt8',
|
312
|
-
# 'datetime64[ns]': 'DateTime',
|
313
|
-
# 'object': 'String',
|
314
|
-
# 'category': 'String',
|
315
|
-
# }
|
316
|
-
# df: dd.DataFrame
|
317
|
-
#
|
318
|
-
# def __init__(self, **kwargs):
|
319
|
-
# super().__init__(**kwargs)
|
320
|
-
# self.clickhouse_host = kwargs.setdefault('host', "localhost")
|
321
|
-
# self.clickhouse_port = kwargs.setdefault('port', 8123)
|
322
|
-
# self.clickhouse_dbname = kwargs.setdefault('database', 'sibi_data')
|
323
|
-
# self.clickhouse_user = kwargs.setdefault('user', 'default')
|
324
|
-
# self.clickhouse_password = kwargs.setdefault('password', '')
|
325
|
-
# self.clickhouse_table = kwargs.setdefault('table', 'test_sibi_table')
|
326
|
-
#
|
327
|
-
# #self.logger = logger or Logger.default_logger(logger_name=self.__class__.__name__)
|
328
|
-
# self.client = None
|
329
|
-
# self.order_by = kwargs.setdefault('order_by', 'id')
|
330
|
-
#
|
331
|
-
# def save_to_clickhouse(self, df, **kwargs):
|
332
|
-
# self.df = df.copy()
|
333
|
-
# self.order_by = kwargs.setdefault('order_by', self.order_by)
|
334
|
-
# if len(self.df.head().index) == 0:
|
335
|
-
# self.logger.debug("Dataframe is empty")
|
336
|
-
# return
|
337
|
-
# self._handle_missing_values()
|
338
|
-
# self._connect()
|
339
|
-
# self._drop_table()
|
340
|
-
# self._create_table_from_dask()
|
341
|
-
# self._write_data()
|
342
|
-
#
|
343
|
-
# def _connect(self):
|
344
|
-
# try:
|
345
|
-
# self.client = clickhouse_connect.get_client(
|
346
|
-
# host=self.clickhouse_host,
|
347
|
-
# port=self.clickhouse_port,
|
348
|
-
# database=self.clickhouse_dbname,
|
349
|
-
# user=self.clickhouse_user,
|
350
|
-
# password=self.clickhouse_password
|
351
|
-
# )
|
352
|
-
# self.logger.debug("Connected to ClickHouse")
|
353
|
-
# except Exception as e:
|
354
|
-
# self.logger.error(e)
|
355
|
-
# raise
|
356
|
-
#
|
357
|
-
# @staticmethod
|
358
|
-
# def _generate_clickhouse_schema(dask_dtypes, dtype_map):
|
359
|
-
# schema = []
|
360
|
-
# for col, dtype in dask_dtypes.items():
|
361
|
-
# # Handle pandas nullable types explicitly
|
362
|
-
# if isinstance(dtype, pd.Int64Dtype): # pandas nullable Int64
|
363
|
-
# clickhouse_type = 'Int64'
|
364
|
-
# elif isinstance(dtype, pd.Float64Dtype): # pandas nullable Float64
|
365
|
-
# clickhouse_type = 'Float64'
|
366
|
-
# elif isinstance(dtype, pd.BooleanDtype): # pandas nullable Boolean
|
367
|
-
# clickhouse_type = 'UInt8'
|
368
|
-
# elif isinstance(dtype, pd.DatetimeTZDtype) or 'datetime' in str(dtype): # Nullable datetime
|
369
|
-
# clickhouse_type = 'Nullable(DateTime)'
|
370
|
-
# elif isinstance(dtype, pd.StringDtype): # pandas nullable String
|
371
|
-
# clickhouse_type = 'String'
|
372
|
-
# else:
|
373
|
-
# # Default mapping using the provided dtype_map
|
374
|
-
# clickhouse_type = dtype_map.get(str(dtype), 'String')
|
375
|
-
# schema.append(f"`{col}` {clickhouse_type}")
|
376
|
-
# return ', '.join(schema)
|
377
|
-
#
|
378
|
-
# def _drop_table(self):
|
379
|
-
# if self.client:
|
380
|
-
# self.client.command('DROP TABLE IF EXISTS {}'.format(self.clickhouse_table))
|
381
|
-
# self.logger.debug(f"Dropped table {self.clickhouse_table}")
|
382
|
-
#
|
383
|
-
# def _create_table_from_dask(self, engine=None):
|
384
|
-
# if engine is None:
|
385
|
-
# engine = f"ENGINE = MergeTree() order by {self.order_by}"
|
386
|
-
# dtypes = self.df.dtypes
|
387
|
-
# clickhouse_schema = self._generate_clickhouse_schema(dtypes, self.dtype_to_clickhouse)
|
388
|
-
# create_table_sql = f"CREATE TABLE IF NOT EXISTS {self.clickhouse_table} ({clickhouse_schema}) {engine};"
|
389
|
-
# self.logger.debug(f"Creating table SQL:{create_table_sql}")
|
390
|
-
# if self.client:
|
391
|
-
# self.client.command(create_table_sql)
|
392
|
-
# self.logger.debug("Created table '{}'".format(self.clickhouse_table))
|
393
|
-
#
|
394
|
-
# def _handle_missing_values(self):
|
395
|
-
# """
|
396
|
-
# Handle missing values in the Dask DataFrame before writing to ClickHouse.
|
397
|
-
# """
|
398
|
-
# self.logger.debug("Checking for missing values...")
|
399
|
-
# missing_counts = self.df.isnull().sum().compute()
|
400
|
-
# self.logger.debug(f"Missing values per column:\n{missing_counts}")
|
401
|
-
#
|
402
|
-
# # Replace missing values based on column types
|
403
|
-
# def replace_missing_values(df):
|
404
|
-
# for col in df.columns:
|
405
|
-
# if pd.api.types.is_integer_dtype(df[col]):
|
406
|
-
# df[col] = df[col].fillna(0) # Replace NA with 0 for integers
|
407
|
-
# elif pd.api.types.is_float_dtype(df[col]):
|
408
|
-
# df[col] = df[col].fillna(0.0) # Replace NA with 0.0 for floats
|
409
|
-
# elif pd.api.types.is_bool_dtype(df[col]):
|
410
|
-
# df[col] = df[col].fillna(False) # Replace NA with False for booleans
|
411
|
-
# else:
|
412
|
-
# df[col] = df[col].fillna('') # Replace NA with empty string for other types
|
413
|
-
# return df
|
414
|
-
#
|
415
|
-
# # Apply replacement
|
416
|
-
# self.df = replace_missing_values(self.df)
|
417
|
-
# self.logger.debug("Missing values replaced.")
|
418
|
-
#
|
419
|
-
# def _write_data(self):
|
420
|
-
# """
|
421
|
-
# Writes the Dask DataFrame to a ClickHouse table partition by partition.
|
422
|
-
# """
|
423
|
-
# if len(self.df.index) == 0:
|
424
|
-
# self.logger.debug("No data found. Nothing written.")
|
425
|
-
# return
|
426
|
-
#
|
427
|
-
# for i, partition in enumerate(self.df.to_delayed()):
|
428
|
-
# try:
|
429
|
-
# # Compute the current partition into a pandas DataFrame
|
430
|
-
# df = partition.compute()
|
431
|
-
#
|
432
|
-
# if df.empty:
|
433
|
-
# self.logger.debug(f"Partition {i} is empty. Skipping...")
|
434
|
-
# continue
|
435
|
-
#
|
436
|
-
# self.logger.debug(f"Writing partition {i} with {len(df)} rows to ClickHouse.")
|
437
|
-
#
|
438
|
-
# # Write the partition to the ClickHouse table
|
439
|
-
# self.client.insert_df(self.clickhouse_table, df)
|
440
|
-
# except Exception as e:
|
441
|
-
# self.logger.error(f"Error writing partition {i}: {e}")
|
442
|
-
#
|
443
|
-
# def _write_data_multi_not_working_yet(self):
|
444
|
-
# """
|
445
|
-
# Writes the Dask DataFrame to a ClickHouse table partition by partition.
|
446
|
-
# Ensures a separate client instance is used per thread to avoid session conflicts.
|
447
|
-
# """
|
448
|
-
# if len(self.df.index) == 0:
|
449
|
-
# self.logger.debug("No data found. Nothing written.")
|
450
|
-
# return
|
451
|
-
#
|
452
|
-
# def create_client():
|
453
|
-
# client = Client(
|
454
|
-
# host=self.clickhouse_host,
|
455
|
-
# port=self.clickhouse_port,
|
456
|
-
# database=self.clickhouse_dbname,
|
457
|
-
# user=self.clickhouse_user,
|
458
|
-
# password=self.clickhouse_password
|
459
|
-
# )
|
460
|
-
# """
|
461
|
-
# Create a new instance of the ClickHouse client for each thread.
|
462
|
-
# This avoids session conflicts during concurrent writes.
|
463
|
-
# """
|
464
|
-
# return client
|
465
|
-
#
|
466
|
-
# def write_partition(partition, index):
|
467
|
-
# """
|
468
|
-
# Write a single partition to ClickHouse using a separate client instance.
|
469
|
-
# """
|
470
|
-
# try:
|
471
|
-
# self.logger.debug(f"Starting to process partition {index}")
|
472
|
-
# client = create_client() # Create a new client for the thread
|
473
|
-
#
|
474
|
-
# # Compute the Dask partition into a Pandas DataFrame
|
475
|
-
# df = partition.compute()
|
476
|
-
# if df.empty:
|
477
|
-
# self.logger.debug(f"Partition {index} is empty. Skipping...")
|
478
|
-
# return
|
479
|
-
#
|
480
|
-
# # Convert DataFrame to list of tuples
|
481
|
-
# data = [tuple(row) for row in df.to_numpy()]
|
482
|
-
# columns = df.columns.tolist()
|
483
|
-
#
|
484
|
-
# # Perform the insert
|
485
|
-
# self.logger.debug(f"Writing partition {index} with {len(df)} rows to ClickHouse.")
|
486
|
-
# client.execute(f"INSERT INTO {self.clickhouse_table} ({', '.join(columns)}) VALUES", data)
|
487
|
-
#
|
488
|
-
# except Exception as e:
|
489
|
-
# self.logger.error(f"Error writing partition {index}: {e}")
|
490
|
-
# finally:
|
491
|
-
# if 'client' in locals() and hasattr(client, 'close'):
|
492
|
-
# client.close()
|
493
|
-
# self.logger.debug(f"Closed client for partition {index}")
|
494
|
-
#
|
495
|
-
# try:
|
496
|
-
# # Get delayed partitions and enumerate them
|
497
|
-
# partitions = self.df.to_delayed()
|
498
|
-
# with ThreadPoolExecutor() as executor:
|
499
|
-
# executor.map(write_partition, partitions, range(len(partitions)))
|
500
|
-
# except Exception as e:
|
501
|
-
# self.logger.error(f"Error during multi-partition write: {e}")
|
sibi_dst/utils/storage_config.py
CHANGED
@@ -6,13 +6,13 @@ from .storage_manager import StorageManager
|
|
6
6
|
from .credentials import ConfigManager
|
7
7
|
|
8
8
|
class StorageConfig:
|
9
|
-
def __init__(self, config:ConfigManager, depots:dict=None):
|
9
|
+
def __init__(self, config:ConfigManager, depots:dict=None, clear_existing=False, write_mode="full-access"):
|
10
10
|
self.conf = config
|
11
11
|
self.depots = depots
|
12
12
|
self._initialize_storage()
|
13
13
|
self.storage_manager = StorageManager(self.base_storage, self.filesystem_type, self.filesystem_options)
|
14
14
|
if self.depots is not None:
|
15
|
-
self.depot_paths, self.depot_names = self.storage_manager.rebuild_depot_paths(depots)
|
15
|
+
self.depot_paths, self.depot_names = self.storage_manager.rebuild_depot_paths(depots, clear_existing=clear_existing, write_mode=write_mode)
|
16
16
|
else:
|
17
17
|
self.depot_paths = None
|
18
18
|
self.depot_names = None
|
@@ -0,0 +1,195 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
import pandas as pd
|
3
|
+
import dask.dataframe as dd
|
4
|
+
from typing import Iterable, Optional, List, Tuple, Union
|
5
|
+
import fsspec
|
6
|
+
|
7
|
+
DNFFilter = List[List[Tuple[str, str, Union[str, int]]]]
|
8
|
+
|
9
|
+
|
10
|
+
class HiveDatePartitionedStore:
|
11
|
+
"""
|
12
|
+
Dask-only Parquet store with Hive-style yyyy=…/mm=…/dd=… partitions.
|
13
|
+
|
14
|
+
- `write(...)` safely "overwrites" S3 prefixes via per-object deletes (no bulk DeleteObjects).
|
15
|
+
- `read_range(...)` builds DNF filters and auto-matches partition types (string vs int).
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(
|
19
|
+
self,
|
20
|
+
path: str,
|
21
|
+
*,
|
22
|
+
filesystem=None, # fsspec filesystem or None to infer from path
|
23
|
+
date_col: str = "tracking_dt",
|
24
|
+
compression: str = "zstd",
|
25
|
+
partition_values_as_strings: bool = True, # keep mm=07, dd=01 folder names
|
26
|
+
logger=None,
|
27
|
+
) -> None:
|
28
|
+
self.path = path
|
29
|
+
self.fs = filesystem or fsspec.open(path).fs
|
30
|
+
self.date_col = date_col
|
31
|
+
self.compression = compression
|
32
|
+
self.partition_values_as_strings = partition_values_as_strings
|
33
|
+
self.log = logger
|
34
|
+
|
35
|
+
# ----------------- public API -----------------
|
36
|
+
|
37
|
+
def write(
|
38
|
+
self,
|
39
|
+
df: dd.DataFrame,
|
40
|
+
*,
|
41
|
+
repartition: Optional[int] = None,
|
42
|
+
overwrite: bool = False,
|
43
|
+
) -> None:
|
44
|
+
"""Write Dask DataFrame to Hive-style yyyy/mm/dd partitions."""
|
45
|
+
self._require_col(df, self.date_col)
|
46
|
+
ser = dd.to_datetime(df[self.date_col], errors="coerce")
|
47
|
+
|
48
|
+
if self.partition_values_as_strings:
|
49
|
+
parts = {
|
50
|
+
"yyyy": ser.dt.strftime("%Y"),
|
51
|
+
"mm": ser.dt.strftime("%m"),
|
52
|
+
"dd": ser.dt.strftime("%d"),
|
53
|
+
}
|
54
|
+
else:
|
55
|
+
parts = {
|
56
|
+
"yyyy": ser.dt.year.astype("int32"),
|
57
|
+
"mm": ser.dt.month.astype("int8"),
|
58
|
+
"dd": ser.dt.day.astype("int8"),
|
59
|
+
}
|
60
|
+
|
61
|
+
df = df.assign(**{self.date_col: ser}, **parts)
|
62
|
+
|
63
|
+
if repartition:
|
64
|
+
df = df.repartition(npartitions=repartition)
|
65
|
+
|
66
|
+
if overwrite:
|
67
|
+
self._safe_rm_prefix(self.path)
|
68
|
+
|
69
|
+
if self.log:
|
70
|
+
self.log.info(f"Writing parquet to {self.path} (hive yyyy/mm/dd)…")
|
71
|
+
|
72
|
+
df.to_parquet(
|
73
|
+
self.path,
|
74
|
+
engine="pyarrow",
|
75
|
+
write_index=False,
|
76
|
+
filesystem=self.fs,
|
77
|
+
partition_on=["yyyy", "mm", "dd"],
|
78
|
+
compression=self.compression,
|
79
|
+
overwrite=False, # we pre-cleaned if overwrite=True
|
80
|
+
)
|
81
|
+
|
82
|
+
def read_range(
|
83
|
+
self,
|
84
|
+
start: Union[str, pd.Timestamp],
|
85
|
+
end: Union[str, pd.Timestamp],
|
86
|
+
*,
|
87
|
+
columns: Optional[Iterable[str]] = None,
|
88
|
+
) -> dd.DataFrame:
|
89
|
+
"""
|
90
|
+
Read a date window with partition pruning. Tries string filters first,
|
91
|
+
falls back to integer filters if Arrow infers partition types as ints.
|
92
|
+
"""
|
93
|
+
str_filters = self._dnf_filters_for_range_str(start, end)
|
94
|
+
try:
|
95
|
+
return dd.read_parquet(
|
96
|
+
self.path,
|
97
|
+
engine="pyarrow",
|
98
|
+
filesystem=self.fs,
|
99
|
+
columns=list(columns) if columns else None,
|
100
|
+
filters=str_filters,
|
101
|
+
)
|
102
|
+
except Exception:
|
103
|
+
int_filters = self._dnf_filters_for_range_int(start, end)
|
104
|
+
return dd.read_parquet(
|
105
|
+
self.path,
|
106
|
+
engine="pyarrow",
|
107
|
+
filesystem=self.fs,
|
108
|
+
columns=list(columns) if columns else None,
|
109
|
+
filters=int_filters,
|
110
|
+
)
|
111
|
+
|
112
|
+
# Convenience: full month / single day
|
113
|
+
def read_month(self, year: int, month: int, *, columns=None) -> dd.DataFrame:
|
114
|
+
start = pd.Timestamp(year=year, month=month, day=1)
|
115
|
+
end = (start + pd.offsets.MonthEnd(0))
|
116
|
+
return self.read_range(start, end, columns=columns)
|
117
|
+
|
118
|
+
def read_day(self, year: int, month: int, day: int, *, columns=None) -> dd.DataFrame:
|
119
|
+
ts = pd.Timestamp(year=year, month=month, day=day)
|
120
|
+
return self.read_range(ts, ts, columns=columns)
|
121
|
+
|
122
|
+
# ----------------- internals -----------------
|
123
|
+
|
124
|
+
@staticmethod
|
125
|
+
def _pad2(n: int) -> str:
|
126
|
+
return f"{n:02d}"
|
127
|
+
|
128
|
+
def _safe_rm_prefix(self, path: str) -> None:
|
129
|
+
"""Per-object delete to avoid S3 bulk DeleteObjects (and Content-MD5 issues)."""
|
130
|
+
if not self.fs.exists(path):
|
131
|
+
return
|
132
|
+
if self.log:
|
133
|
+
self.log.info(f"Cleaning prefix (safe delete): {path}")
|
134
|
+
for k in self.fs.find(path):
|
135
|
+
try:
|
136
|
+
(self.fs.rm_file(k) if hasattr(self.fs, "rm_file") else self.fs.rm(k, recursive=False))
|
137
|
+
except Exception as e:
|
138
|
+
if self.log:
|
139
|
+
self.log.warning(f"Could not delete {k}: {e}")
|
140
|
+
|
141
|
+
@staticmethod
|
142
|
+
def _require_col(df: dd.DataFrame, col: str) -> None:
|
143
|
+
if col not in df.columns:
|
144
|
+
raise KeyError(f"'{col}' not in DataFrame")
|
145
|
+
|
146
|
+
# ---- DNF builders (string vs int) ----
|
147
|
+
def _dnf_filters_for_range_str(self, start, end) -> DNFFilter:
|
148
|
+
s, e = pd.Timestamp(start), pd.Timestamp(end)
|
149
|
+
if s > e:
|
150
|
+
raise ValueError("start > end")
|
151
|
+
sY, sM, sD = s.year, s.month, s.day
|
152
|
+
eY, eM, eD = e.year, e.month, e.day
|
153
|
+
p2 = self._pad2
|
154
|
+
if sY == eY and sM == eM:
|
155
|
+
return [[("yyyy","==",str(sY)),("mm","==",p2(sM)),("dd",">=",p2(sD)),("dd","<=",p2(eD))]]
|
156
|
+
clauses: DNFFilter = [
|
157
|
+
[("yyyy","==",str(sY)),("mm","==",p2(sM)),("dd",">=",p2(sD))],
|
158
|
+
[("yyyy","==",str(eY)),("mm","==",p2(eM)),("dd","<=",p2(eD))]
|
159
|
+
]
|
160
|
+
if sY == eY:
|
161
|
+
for m in range(sM+1, eM):
|
162
|
+
clauses.append([("yyyy","==",str(sY)),("mm","==",p2(m))])
|
163
|
+
return clauses
|
164
|
+
for m in range(sM+1, 13):
|
165
|
+
clauses.append([("yyyy","==",str(sY)),("mm","==",p2(m))])
|
166
|
+
for y in range(sY+1, eY):
|
167
|
+
clauses.append([("yyyy","==",str(y))])
|
168
|
+
for m in range(1, eM):
|
169
|
+
clauses.append([("yyyy","==",str(eY)),("mm","==",p2(m))])
|
170
|
+
return clauses
|
171
|
+
|
172
|
+
@staticmethod
|
173
|
+
def _dnf_filters_for_range_int(start, end) -> DNFFilter:
|
174
|
+
s, e = pd.Timestamp(start), pd.Timestamp(end)
|
175
|
+
if s > e:
|
176
|
+
raise ValueError("start > end")
|
177
|
+
sY, sM, sD = s.year, s.month, s.day
|
178
|
+
eY, eM, eD = e.year, e.month, e.day
|
179
|
+
if sY == eY and sM == eM:
|
180
|
+
return [[("yyyy","==",sY),("mm","==",sM),("dd",">=",sD),("dd","<=",eD)]]
|
181
|
+
clauses: DNFFilter = [
|
182
|
+
[("yyyy","==",sY),("mm","==",sM),("dd",">=",sD)],
|
183
|
+
[("yyyy","==",eY),("mm","==",eM),("dd","<=",eD)],
|
184
|
+
]
|
185
|
+
if sY == eY:
|
186
|
+
for m in range(sM+1, eM):
|
187
|
+
clauses.append([("yyyy","==",sY),("mm","==",m)])
|
188
|
+
return clauses
|
189
|
+
for m in range(sM+1, 13):
|
190
|
+
clauses.append([("yyyy","==",sY),("mm","==",m)])
|
191
|
+
for y in range(sY+1, eY):
|
192
|
+
clauses.append([("yyyy","==",y)])
|
193
|
+
for m in range(1, eM):
|
194
|
+
clauses.append([("yyyy","==",eY),("mm","==",m)])
|
195
|
+
return clauses
|
@@ -83,7 +83,7 @@ class StorageManager:
|
|
83
83
|
self.fs.rm(sub_path, recursive=True)
|
84
84
|
self.fs.mkdirs(sub_path, exist_ok=True)
|
85
85
|
|
86
|
-
def rebuild_depot_paths(self, depots, clear_existing=False):
|
86
|
+
def rebuild_depot_paths(self, depots, clear_existing=False, write_mode="full-access"):
|
87
87
|
"""
|
88
88
|
Rebuilds depot_paths (dictionary) and depot_name (SimpleNamespace).
|
89
89
|
Handles clear_existing scenario by resetting directories when required.
|
@@ -96,7 +96,8 @@ class StorageManager:
|
|
96
96
|
depot_path = self.join_paths(self.storage_path, depot)
|
97
97
|
if self.debug:
|
98
98
|
print(f"Rebuilding depot at: {depot_path}")
|
99
|
-
|
99
|
+
if write_mode == "full-access":
|
100
|
+
self.setup_directories(depot_path, sub_directories, clear_existing=clear_existing)
|
100
101
|
|
101
102
|
# Generate depot_paths dictionary
|
102
103
|
self.depot_paths = {
|
@@ -2,7 +2,7 @@ sibi_dst/__init__.py,sha256=D01Z2Ds4zES8uz5Zp7qOWD0EcfCllWgew7AWt2X1SQg,445
|
|
2
2
|
sibi_dst/df_helper/__init__.py,sha256=CyDXtFhRnMrycktxNO8jGGkP0938QiScl56kMZS1Sf8,578
|
3
3
|
sibi_dst/df_helper/_artifact_updater_async.py,sha256=0lUwel-IkmKewRnmMv9GtuT-P6SivkIKtgOHvKchHlc,8462
|
4
4
|
sibi_dst/df_helper/_artifact_updater_threaded.py,sha256=M5GNZismOqMmBrcyfolP1DPv87VILQf_P18is_epn50,7238
|
5
|
-
sibi_dst/df_helper/_df_helper.py,sha256=
|
5
|
+
sibi_dst/df_helper/_df_helper.py,sha256=IqlfTPnbXyaLLkwn8iaulHLuJ6LlBB3hSR3e5O8ixQ0,14360
|
6
6
|
sibi_dst/df_helper/_parquet_artifact.py,sha256=tqYOjwxHV1MsADmn-RNFuVI_RrEvvmCJHZieRcsVXuc,12334
|
7
7
|
sibi_dst/df_helper/_parquet_reader.py,sha256=tFq0OQVczozbKZou93vscokp2R6O2DIJ1zHbZqVjagc,3069
|
8
8
|
sibi_dst/df_helper/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -37,9 +37,10 @@ sibi_dst/osmnx_helper/utils.py,sha256=HfxrmXVPq3akf68SiwncbAp7XI1ER-zp8YN_doh7Ya
|
|
37
37
|
sibi_dst/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
38
38
|
sibi_dst/tests/test_data_wrapper_class.py,sha256=6uFmZR2DxnxQz49L5jT2ehlKvlLnpUHMLFB_PqqUq7k,3336
|
39
39
|
sibi_dst/utils/__init__.py,sha256=vShNCOMPw8KKwlb4tq5XGrpjqakJ_OE8YDc_xDAWAxI,1302
|
40
|
+
sibi_dst/utils/async_utils.py,sha256=53aywfgq1Q6-0OVr9qR1Sf6g7Qv3I9qunAAR4fjFXBE,351
|
40
41
|
sibi_dst/utils/base.py,sha256=IyObjZ7AaE-YjVU0RLIXNCnQKWwzi5NH2I6D1KfcIyk,8716
|
41
42
|
sibi_dst/utils/business_days.py,sha256=dP0Xj4FhTBIvZZrZYLOHZl5zOpDAgWkD4p_1a7BOT7I,8461
|
42
|
-
sibi_dst/utils/clickhouse_writer.py,sha256=
|
43
|
+
sibi_dst/utils/clickhouse_writer.py,sha256=NngJyJpx2PjUQWsX0YmwCuGdeViK77Wi3HmYqHz3jTc,9544
|
43
44
|
sibi_dst/utils/credentials.py,sha256=cHJPPsmVyijqbUQIq7WWPe-lIallA-mI5RAy3YUuRME,1724
|
44
45
|
sibi_dst/utils/data_from_http_source.py,sha256=AcpKNsqTgN2ClNwuhgUpuNCx62r5_DdsAiKY8vcHEBA,1867
|
45
46
|
sibi_dst/utils/data_utils.py,sha256=7bLidEjppieNoozDFb6OuRY0W995cxg4tiGAlkGfePI,7768
|
@@ -54,8 +55,9 @@ sibi_dst/utils/manifest_manager.py,sha256=9y4cV-Ig8O-ekhApp_UObTY-cTsl-bGnvKIThI
|
|
54
55
|
sibi_dst/utils/parquet_saver.py,sha256=aYBlijqPAn-yuJXhmaRIteAN_IAQZvPh8I8Os2TLGgI,4861
|
55
56
|
sibi_dst/utils/periods.py,sha256=8eTGi-bToa6_a8Vwyg4fkBPryyzft9Nzy-3ToxjqC8c,1434
|
56
57
|
sibi_dst/utils/phone_formatter.py,sha256=oeM22nLjhObENrpItCNeVpkYS4pXRm5hSxdk0M4nvwU,4580
|
57
|
-
sibi_dst/utils/storage_config.py,sha256=
|
58
|
-
sibi_dst/utils/
|
58
|
+
sibi_dst/utils/storage_config.py,sha256=DLtP5jKVM0mdFdgRw6LQfRqyavMjJcCVU7GhsUCRH78,4427
|
59
|
+
sibi_dst/utils/storage_hive.py,sha256=FCF6zSTM_VWBEvSuTjn2bmb69oqsYjSS6nvnSZrJRFY,7123
|
60
|
+
sibi_dst/utils/storage_manager.py,sha256=La1NY79bhRAmHWXp7QcXJZtbHoRboJMgoXOSXbIl1SA,6643
|
59
61
|
sibi_dst/utils/update_planner.py,sha256=smlMHpr1p8guZnP5SyzCe6RsC-XkPOJWIsdeospUyb0,11471
|
60
62
|
sibi_dst/utils/webdav_client.py,sha256=D9J5d1f1qQwHGm5FE5AMVpOPwcU5oD7K8JZoKGP8NpM,5811
|
61
63
|
sibi_dst/v2/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
@@ -78,6 +80,6 @@ sibi_dst/v2/df_helper/core/_params_config.py,sha256=DYx2drDz3uF-lSPzizPkchhy-kxR
|
|
78
80
|
sibi_dst/v2/df_helper/core/_query_config.py,sha256=Y8LVSyaKuVkrPluRDkQoOwuXHQxner1pFWG3HPfnDHM,441
|
79
81
|
sibi_dst/v2/utils/__init__.py,sha256=6H4cvhqTiFufnFPETBF0f8beVVMpfJfvUs6Ne0TQZNY,58
|
80
82
|
sibi_dst/v2/utils/log_utils.py,sha256=rfk5VsLAt-FKpv6aPTC1FToIPiyrnHAFFBAkHme24po,4123
|
81
|
-
sibi_dst-2025.8.
|
82
|
-
sibi_dst-2025.8.
|
83
|
-
sibi_dst-2025.8.
|
83
|
+
sibi_dst-2025.8.7.dist-info/METADATA,sha256=6sDcEFzHqZK8J1kSjtOCT_m-e5peFg4gFHpAGfeZWRw,2610
|
84
|
+
sibi_dst-2025.8.7.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
85
|
+
sibi_dst-2025.8.7.dist-info/RECORD,,
|
File without changes
|