8fa36b774e
Key changes: - New: data_loader.py SQLite persistence with drop_sqlite_table - New: db_utils.py retry_on_lock decorator (3 retries, exponential backoff) - New: tool_registry.py with 27 MCP tools (filter_and_cluster, compute_scores, etc.) - New: tls_ref.py for TLS cipher/reference data - New: import_tlsdb.py management command - New: scripts/start_server.py for portable runtime - New: migrations 0003-0007 for SQLite table, display_id, llm fields - Changed: views.py unified pipeline worker, retry_run, display_id everywhere - Changed: models.py with display_id auto-assignment, run_type, sqlite_table, llm_thinking, tool_calls_json - Changed: urls.py added retry_run route - Changed: session_store.py robust JSON persistence - Changed: AGENTS.md v7 fix summary added - Changed: templates — globe rewrite (inertia/polar flip), auto.html (thinking/tool accordions), base.html (toast/config), all pages use display_id - Changed: run.bat PYTHONUTF8=1 - Deleted: entity_detector.py, entity_aggregator.py (replaced by filter_and_cluster clustering pipeline) - Test: 92/92 unit tests passing
582 lines
22 KiB
Python
582 lines
22 KiB
Python
"""CSV loading with per-file schema validation, BOM detection, and multi-file merge.
|
||
|
||
Supports multiple encodings and delimiter-separated files. The main entry
|
||
point is :func:`load_csv_directory`, which globs files, detects BOM per file,
|
||
validates schema consistency, and returns a merged :class:`pl.LazyFrame`.
|
||
"""
|
||
import glob
|
||
import logging
|
||
import os
|
||
import sqlite3
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import polars as pl
|
||
import yaml
|
||
|
||
from analysis.type_classifier import classify_schema, DataType
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
SUPPORTED_ENCODINGS = ['utf-8', 'utf-8-sig', 'utf-16le', 'utf-16be', 'latin-1']
|
||
|
||
|
||
# ── numeric coercion helper ──────────────────────────────────────────────
|
||
|
||
def _coerce_to_float(series: pl.Series) -> pl.Series:
|
||
"""Convert string series to Float64.
|
||
|
||
Every entry that doesn't parse as a valid float becomes null.
|
||
Handles blank strings, standalone '+'/'-', 'N/A', 'null', 'None', etc.
|
||
"""
|
||
series = series.cast(pl.Utf8).str.strip_chars()
|
||
artifacts = ('', '+', '-', '.', '..', 'N/A', 'n/a', 'NA', 'null', 'None', 'NULL', '?', '---', '--', '/')
|
||
is_artifact = series.is_in(pl.Series(artifacts).implode())
|
||
result = []
|
||
for v, bad in zip(series.to_list(), is_artifact.to_list()):
|
||
if v is None or bad:
|
||
result.append(None)
|
||
else:
|
||
# Check for "+"/"-" prefix artifacts (e.g., "+abc", "-xyz")
|
||
# where the value isn't a genuine numeric string. Values like
|
||
# "+5" or "-3.14" are still accepted as valid floats below.
|
||
if len(v) > 1 and v[0] in '+-':
|
||
try:
|
||
float(v[1:]) # validate suffix minus prefix
|
||
except ValueError:
|
||
result.append(None)
|
||
continue
|
||
try:
|
||
result.append(float(v))
|
||
except (ValueError, TypeError):
|
||
result.append(None)
|
||
return pl.Series(result, dtype=pl.Float64)
|
||
|
||
|
||
# ── BOM detection ───────────────────────────────────────────────────────
|
||
|
||
def detect_bom(path: str) -> str:
|
||
"""Read the first 4 bytes of *path* and return the detected encoding.
|
||
|
||
Returns ``'utf-8'`` when no BOM is found.
|
||
|
||
Note: Python 3 ``open()`` fully supports Unicode/Chinese paths on all
|
||
platforms (no encoding dance needed).
|
||
"""
|
||
with open(path, 'rb') as f:
|
||
header = f.read(4)
|
||
|
||
if header[:3] == b'\xef\xbb\xbf':
|
||
return 'utf-8-sig'
|
||
if header[:2] == b'\xff\xfe':
|
||
return 'utf-16le'
|
||
if header[:2] == b'\xfe\xff':
|
||
return 'utf-16be'
|
||
return 'utf-8'
|
||
|
||
|
||
# ── schema utilities ────────────────────────────────────────────────────
|
||
|
||
def collect_schema(
|
||
path: str,
|
||
encoding: str = 'utf-8',
|
||
delimiter: str = ',',
|
||
sample_rows: int = 10000,
|
||
) -> dict:
|
||
"""Return {column_name: dtype_string} for a single CSV file.
|
||
|
||
Uses :func:`pl.scan_csv` with *sample_rows* rows for type inference.
|
||
"""
|
||
lf = pl.scan_csv(
|
||
str(path), # str() ensures Unicode/Chinese paths resolve correctly
|
||
encoding=encoding,
|
||
separator=delimiter,
|
||
infer_schema_length=sample_rows,
|
||
)
|
||
schema = lf.collect_schema()
|
||
return {name: str(dtype) for name, dtype in zip(schema.names(), schema.dtypes())}
|
||
|
||
|
||
def _merge_schema_entries(
|
||
schemas: list[dict],
|
||
) -> dict:
|
||
"""Merge multiple schema dicts, raising on type conflicts.
|
||
|
||
Returns a single {name: dtype} dict. If a column appears in multiple
|
||
files with different dtypes the *last* non-null dtype wins (with a
|
||
warning embedded in the result).
|
||
"""
|
||
merged: dict[str, str] = {}
|
||
conflicts: list[str] = []
|
||
for s in schemas:
|
||
for name, dtype in s.items():
|
||
if name in merged and merged[name] != dtype:
|
||
conflicts.append(name)
|
||
merged[name] = dtype # last wins
|
||
return merged, conflicts
|
||
|
||
|
||
def validate_schemas(schemas: list[dict]) -> None:
|
||
"""Compare schemas across files and raise :class:`ValueError` on mismatch.
|
||
|
||
Checks that every file has the same set of column names. Type
|
||
differences across files are **not** considered an error (Polars can
|
||
cast), but differences in *column sets* are.
|
||
"""
|
||
if not schemas:
|
||
return
|
||
|
||
col_sets = [set(s.keys()) for s in schemas]
|
||
reference = col_sets[0]
|
||
|
||
for i, cs in enumerate(col_sets[1:], start=1):
|
||
only_in_ref = reference - cs
|
||
only_in_current = cs - reference
|
||
if only_in_ref or only_in_current:
|
||
msg_parts = []
|
||
if only_in_ref:
|
||
msg_parts.append(f"File #{i} missing columns: {sorted(only_in_ref)}")
|
||
if only_in_current:
|
||
msg_parts.append(f"File #{i} has extra columns: {sorted(only_in_current)}")
|
||
raise ValueError(f"Schema mismatch: {'; '.join(msg_parts)}")
|
||
|
||
|
||
# ── dtype inference from schema ─────────────────────────────────────────
|
||
|
||
def detect_schema_dtypes(
|
||
lf: pl.LazyFrame,
|
||
sample_rows: int = 10000,
|
||
) -> dict:
|
||
"""Infer Polars dtype strings from a LazyFrame's schema.
|
||
|
||
The schema is read without collecting the full data (the scan scans
|
||
a small sample).
|
||
"""
|
||
schema = lf.collect_schema()
|
||
return {name: str(dtype) for name, dtype in zip(schema.names(), schema.dtypes())}
|
||
|
||
|
||
# ── config loading ──────────────────────────────────────────────────────
|
||
|
||
def load_config(config_path: Optional[str]) -> dict:
|
||
"""Load a YAML config file; returns an empty dict if *config_path* is None."""
|
||
if config_path is None:
|
||
return {}
|
||
path = Path(config_path)
|
||
if not path.exists():
|
||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||
with open(path, 'r', encoding='utf-8') as f:
|
||
return yaml.safe_load(f) or {}
|
||
|
||
|
||
# ── SQLite persistence ──────────────────────────────────────────────────
|
||
|
||
def _dtype_to_sqlite(dtype: pl.DataType) -> str:
|
||
"""Map a Polars dtype to a SQLite column type affinity."""
|
||
if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
|
||
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64):
|
||
return 'INTEGER'
|
||
if dtype in (pl.Float32, pl.Float64):
|
||
return 'REAL'
|
||
if dtype == pl.Boolean:
|
||
return 'INTEGER'
|
||
return 'TEXT' # String, Date, Datetime, etc. all map to TEXT
|
||
|
||
|
||
def save_to_db(run_id: int, lf: pl.LazyFrame, mode: str = 'replace') -> str:
|
||
"""Write a LazyFrame to a SQLite table ``_data_{run_id}``.
|
||
|
||
Collects the LazyFrame (streaming if possible), creates or replaces the
|
||
table, bulk-inserts rows in chunks, and returns the table name.
|
||
|
||
Args:
|
||
run_id: Analysis run ID (used as ``_data_{run_id}``).
|
||
lf: LazyFrame to persist.
|
||
mode: ``'replace'`` (default) → drop+create+insert.
|
||
``'append'`` → skip DDL, only INSERT (table must exist).
|
||
|
||
This is a *persistence copy* — the original CSV files remain untouched.
|
||
"""
|
||
from django.conf import settings
|
||
|
||
table_name = f"_data_{run_id}"
|
||
db_path = settings.DATABASES['default']['NAME']
|
||
|
||
df = lf.collect(streaming=True)
|
||
conn = sqlite3.connect(db_path)
|
||
|
||
try:
|
||
if mode == 'replace':
|
||
# Drop existing table for retry/idempotency safety
|
||
conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
|
||
|
||
# Build CREATE TABLE from Polars schema
|
||
col_defs = [f'"{name}" {_dtype_to_sqlite(dtype)}'
|
||
for name, dtype in zip(df.columns, df.dtypes)]
|
||
conn.execute(f'CREATE TABLE "{table_name}" ({", ".join(col_defs)})')
|
||
|
||
# Bulk insert in chunks
|
||
col_names = [f'"{c}"' for c in df.columns]
|
||
placeholders = ','.join(['?' for _ in df.columns])
|
||
insert_sql = f'INSERT INTO "{table_name}" ({", ".join(col_names)}) VALUES ({placeholders})'
|
||
|
||
total_rows = len(df)
|
||
chunk_size = 10000
|
||
for i in range(0, total_rows, chunk_size):
|
||
chunk = df.slice(i, chunk_size)
|
||
conn.executemany(insert_sql, chunk.rows())
|
||
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
logger.info('[SAVE_TO_DB] table=%s rows=%d mode=%s', table_name, total_rows, mode)
|
||
return table_name
|
||
|
||
|
||
def load_from_db(table_name: str) -> Optional[pl.LazyFrame]:
|
||
"""Load a SQLite table into a LazyFrame.
|
||
|
||
Returns ``None`` if the table does not exist.
|
||
"""
|
||
from django.conf import settings
|
||
|
||
db_path = settings.DATABASES['default']['NAME']
|
||
conn = sqlite3.connect(db_path)
|
||
|
||
try:
|
||
# Check table exists
|
||
cursor = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||
(table_name,),
|
||
)
|
||
if cursor.fetchone() is None:
|
||
return None
|
||
|
||
conn.row_factory = sqlite3.Row
|
||
rows = conn.execute(f'SELECT * FROM "{table_name}"').fetchall()
|
||
if not rows:
|
||
return pl.DataFrame().lazy()
|
||
df = pl.from_dicts([dict(r) for r in rows])
|
||
return df.lazy()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def drop_sqlite_table(table_name: str) -> None:
|
||
"""Drop a SQLite data table silently if it exists."""
|
||
from django.conf import settings
|
||
|
||
db_path = settings.DATABASES['default']['NAME']
|
||
conn = sqlite3.connect(db_path)
|
||
try:
|
||
conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
# ── helpers ─────────────────────────────────────────────────────────────
|
||
|
||
def _file_count(paths: list[str]) -> int:
|
||
return len(paths)
|
||
|
||
|
||
def _resolve_encoding(encoding: str, file_encoding: str) -> str:
|
||
"""BOM-detected encoding overrides user-supplied encoding.
|
||
|
||
Polars >= 1.0 requires ``'utf8'`` (not ``'utf-8'``).
|
||
"""
|
||
resolved = file_encoding if file_encoding != 'utf-8' else encoding
|
||
return resolved.replace('-', '') if resolved in ('utf-8', 'utf-8-sig') else resolved
|
||
|
||
|
||
# ── main entry ──────────────────────────────────────────────────────────
|
||
|
||
def load_csv_directory(
|
||
glob_pattern: str,
|
||
encoding: str = 'utf-8',
|
||
delimiter: str = ',',
|
||
config_path: Optional[str] = None,
|
||
schema_override: Optional[dict] = None,
|
||
sample_rows: int = 10000,
|
||
schema_strict: bool = False,
|
||
recursive: bool = False,
|
||
head: Optional[int] = None,
|
||
) -> tuple[pl.LazyFrame, dict, int, int, float]:
|
||
"""Load, validate, and merge CSV files matching *glob_pattern*.
|
||
|
||
Args:
|
||
glob_pattern: Glob pattern for CSV files (e.g. ``data/*.csv``).
|
||
encoding: Fallback encoding when no BOM is detected.
|
||
delimiter: Column delimiter character.
|
||
config_path: Optional path to a YAML config (merged into metadata).
|
||
schema_override: If given, force these {column: dtype} overrides.
|
||
sample_rows: Rows to scan for dtype inference per file.
|
||
schema_strict: When True, raise ValueError on column mismatch across
|
||
files. When False (default), log warnings and merge with
|
||
``diagonal_relaxed`` (missing columns get null).
|
||
recursive: When True, use ``glob.glob(..., recursive=True)`` to
|
||
support ``**/*.csv`` patterns.
|
||
head: Optional row limit for preview scenarios. When set, applies
|
||
``lf.head(head)`` before returning.
|
||
|
||
Returns:
|
||
A tuple of ``(lazyframe, schema_dict, total_row_count, file_count,
|
||
memory_estimate_mb)``.
|
||
|
||
Raises:
|
||
FileNotFoundError: If no files match the glob pattern.
|
||
ValueError: If schemas are inconsistent across files and
|
||
*schema_strict* is True.
|
||
"""
|
||
# Normalise path separators for cross-platform Unicode support
|
||
glob_pattern = os.path.normpath(glob_pattern)
|
||
|
||
# Resolve file list
|
||
paths = sorted(glob.glob(glob_pattern, recursive=recursive))
|
||
if not paths and not recursive:
|
||
# Fallback: try recursive pattern
|
||
paths = sorted(glob.glob(glob_pattern, recursive=True))
|
||
if not paths:
|
||
raise FileNotFoundError(f"No files match glob pattern: {glob_pattern}")
|
||
|
||
# Load config (user-provided or fall back to project default)
|
||
config = load_config(config_path)
|
||
if not config:
|
||
default_path = Path(__file__).parent.parent / 'config' / 'config.yaml'
|
||
if default_path.exists():
|
||
config = load_config(str(default_path))
|
||
|
||
# Collect per-file schema
|
||
schemas: list[dict] = []
|
||
lazyframes: list[pl.LazyFrame] = []
|
||
for path in paths:
|
||
file_encoding = detect_bom(path)
|
||
actual_encoding = _resolve_encoding(encoding, file_encoding)
|
||
|
||
lf = pl.scan_csv(
|
||
str(path), # str() ensures Unicode/Chinese paths resolve correctly
|
||
encoding=actual_encoding,
|
||
separator=delimiter,
|
||
infer_schema_length=max(sample_rows, 1000),
|
||
)
|
||
schema = detect_schema_dtypes(lf, sample_rows=sample_rows)
|
||
|
||
# Apply schema override for this file
|
||
if schema_override:
|
||
for col, dtype in schema_override.items():
|
||
if col in schema:
|
||
schema[col] = dtype
|
||
|
||
schemas.append(schema)
|
||
lazyframes.append(lf)
|
||
|
||
if schema_strict:
|
||
# Raise on column mismatch
|
||
validate_schemas(schemas)
|
||
else:
|
||
# Lenient mode: log warnings for column mismatches
|
||
col_sets = [set(s.keys()) for s in schemas]
|
||
reference = col_sets[0]
|
||
for i, cs in enumerate(col_sets[1:], start=1):
|
||
only_in_ref = reference - cs
|
||
only_in_current = cs - reference
|
||
if only_in_ref:
|
||
logger.warning(
|
||
"File #%d missing columns: %s (will be null)",
|
||
i + 1, sorted(only_in_ref),
|
||
)
|
||
if only_in_current:
|
||
logger.warning(
|
||
"File #%d has extra columns: %s (will be null in other files)",
|
||
i + 1, sorted(only_in_current),
|
||
)
|
||
|
||
# Merge schemas (unify column ordering — first file wins order)
|
||
merged_schema, _ = _merge_schema_entries(schemas)
|
||
|
||
# Concatenate all LazyFrames (union vertically)
|
||
merged_lf: pl.LazyFrame = pl.concat(lazyframes, how='diagonal_relaxed')
|
||
|
||
# Log schema and size estimate
|
||
total_row_count_est = len(paths) * (lazyframes[0].collect(streaming=True).height if lazyframes else 0)
|
||
logger.info(f'[LOAD] files={len(paths)} total_rows_est={total_row_count_est}')
|
||
for col_name, dtype in sorted(merged_schema.items()):
|
||
logger.info(f'[LOAD_SCHEMA] col={col_name} dtype={dtype}')
|
||
|
||
# Apply schema override on merged frame (cast columns)
|
||
if schema_override:
|
||
casts = {}
|
||
for col, dtype_str in schema_override.items():
|
||
if col in merged_schema:
|
||
try:
|
||
polars_dtype = getattr(pl, dtype_str.split('(')[0], None)
|
||
if polars_dtype:
|
||
casts[col] = pl.col(col).cast(polars_dtype)
|
||
except Exception:
|
||
pass # skip uncastable overrides silently
|
||
if casts:
|
||
merged_lf = merged_lf.with_columns(list(casts.values()))
|
||
|
||
# ── Cleaning: drop outdated columns ──────────────────────────────────
|
||
geoip_cfg = config.get('data', {}).get('geoip', {})
|
||
drop_cols = geoip_cfg.get(
|
||
'drop_columns',
|
||
[], # Empty by default — lat/lon columns are now cleaned, not dropped
|
||
)
|
||
existing_drop = [c for c in drop_cols if c in merged_schema]
|
||
if existing_drop:
|
||
merged_lf = merged_lf.drop(existing_drop)
|
||
merged_schema = {k: v for k, v in merged_schema.items() if k not in existing_drop}
|
||
logger.info('[CLEAN] dropped columns: %s', existing_drop)
|
||
|
||
# ── Cleaning: BOOL_ENUM normalisation ────────────────────────────────
|
||
try:
|
||
type_map = classify_schema(
|
||
merged_lf.head(1000),
|
||
config_overrides=config.get('columns', {}),
|
||
)
|
||
except Exception:
|
||
type_map = {}
|
||
|
||
bool_enum_cols = [
|
||
c for c, t in type_map.items()
|
||
if t == DataType.BOOL_ENUM and c in merged_schema
|
||
]
|
||
if bool_enum_cols:
|
||
# Only clean string-typed BOOL_ENUM columns; native Boolean is already clean
|
||
schema_dtypes = dict(zip(
|
||
merged_lf.collect_schema().names(),
|
||
merged_lf.collect_schema().dtypes(),
|
||
))
|
||
bool_exprs = []
|
||
cleaned_names: list[str] = []
|
||
for c in bool_enum_cols:
|
||
if schema_dtypes.get(c) not in (pl.Utf8, pl.String):
|
||
continue # native Boolean / numeric — skip
|
||
c_str = pl.col(c).cast(pl.Utf8).str.strip_chars()
|
||
cleaned = (
|
||
pl.when(c_str == "")
|
||
.then(pl.lit(None, pl.Utf8))
|
||
.when(c_str.str.to_lowercase() == "+")
|
||
.then(pl.lit("True"))
|
||
.when(c_str.str.to_lowercase() == "-")
|
||
.then(pl.lit("False"))
|
||
.otherwise(c_str)
|
||
)
|
||
bool_exprs.append(cleaned.alias(c))
|
||
cleaned_names.append(c)
|
||
if bool_exprs:
|
||
merged_lf = merged_lf.with_columns(bool_exprs)
|
||
for c in cleaned_names:
|
||
merged_schema[c] = 'Utf8'
|
||
logger.info('[CLEAN] BOOL_ENUM columns normalised: %s', cleaned_names)
|
||
|
||
# ── Cleaning: LAT_LON normalisation ────────────────────────────────
|
||
latlon_cols = [
|
||
c for c, t in type_map.items()
|
||
if t == DataType.LAT_LON and c in merged_schema
|
||
]
|
||
if latlon_cols:
|
||
schema_dtypes_ll = dict(zip(
|
||
merged_lf.collect_schema().names(),
|
||
merged_lf.collect_schema().dtypes(),
|
||
))
|
||
latlon_exprs = []
|
||
for c in latlon_cols:
|
||
dtype = schema_dtypes_ll.get(c)
|
||
if dtype in (pl.Utf8, pl.String):
|
||
latlon_exprs.append(
|
||
pl.col(c).map_batches(_coerce_to_float, return_dtype=pl.Float64).alias(c)
|
||
)
|
||
merged_schema[c] = 'Float64'
|
||
if latlon_exprs:
|
||
merged_lf = merged_lf.with_columns(latlon_exprs)
|
||
logger.info('[CLEAN] LAT_LON columns normalised to Float64: %s', latlon_cols)
|
||
|
||
# ── Cleaning: Generic numeric coercion ────────────────────────────
|
||
# For any remaining string column (not IPv4/URL/HEX/ENUM/etc.),
|
||
# try to coerce to Float64. Whatever can't parse becomes null.
|
||
# This prevents "mean on str column" crashes for ANY column.
|
||
_SKIP_NUM_COERCE = (DataType.IPv4, DataType.URL, DataType.HEX,
|
||
DataType.ENUM, DataType.LAT_LON)
|
||
# Also protect columns with these name patterns (MAC addresses, etc.)
|
||
_SKIP_NAME_PATTERNS = ('mac', 'mac_', '_mac', 'addr', 'address')
|
||
schema_dtypes_gn = dict(zip(
|
||
merged_lf.collect_schema().names(),
|
||
merged_lf.collect_schema().dtypes(),
|
||
))
|
||
generic_numeric_cols = []
|
||
for c, t in type_map.items():
|
||
if t in _SKIP_NUM_COERCE:
|
||
continue
|
||
if c not in merged_schema:
|
||
continue
|
||
dtype = schema_dtypes_gn.get(c)
|
||
if dtype not in (pl.Utf8, pl.String):
|
||
continue # already numeric
|
||
# Skip columns with protected name patterns
|
||
cl = c.lower().replace('-', '_').replace(' ', '_')
|
||
if any(p in cl for p in _SKIP_NAME_PATTERNS):
|
||
continue
|
||
generic_numeric_cols.append(c)
|
||
|
||
if generic_numeric_cols:
|
||
num_exprs = []
|
||
for c in generic_numeric_cols:
|
||
num_exprs.append(
|
||
pl.col(c).map_batches(_coerce_to_float, return_dtype=pl.Float64).alias(c)
|
||
)
|
||
if num_exprs:
|
||
merged_lf = merged_lf.with_columns(num_exprs)
|
||
for c in generic_numeric_cols:
|
||
merged_schema[c] = 'Float64'
|
||
logger.info('[CLEAN] Generic numeric coercion applied: %s', generic_numeric_cols)
|
||
|
||
# ── Cleaning: HEX preprocessing (hex_pairs_count) ────────────────────
|
||
hex_cols = [
|
||
c for c, t in type_map.items()
|
||
if t == DataType.HEX and c in merged_schema
|
||
]
|
||
if hex_cols:
|
||
hex_exprs = []
|
||
for c in hex_cols:
|
||
hex_exprs.append(
|
||
pl.col(c).str.split(" ").list.len().alias(f"{c}_hex_pairs_count")
|
||
)
|
||
merged_lf = merged_lf.with_columns(hex_exprs)
|
||
for c in hex_cols:
|
||
merged_schema[f"{c}_hex_pairs_count"] = 'UInt32'
|
||
logger.info('[CLEAN] HEX columns added hex_pairs_count: %s', hex_cols)
|
||
|
||
# Row count estimate (fast path: use first file row count × files)
|
||
# For accurate count we'd need to collect, but that defeats lazy.
|
||
# We estimate from the first file's scanned schema info.
|
||
first_rows = lazyframes[0].collect(streaming=True).height if lazyframes else 0
|
||
total_row_count = first_rows * len(paths)
|
||
|
||
# Memory estimate
|
||
bytes_per_row = 0
|
||
for col_name, dtype_str in merged_schema.items():
|
||
numeric_part = dtype_str.split('(')[0].lower()
|
||
size_map = {
|
||
'int8': 1, 'int16': 2, 'int32': 4, 'int64': 8,
|
||
'uint8': 1, 'uint16': 2, 'uint32': 4, 'uint64': 8,
|
||
'float32': 4, 'float64': 8,
|
||
'boolean': 1, 'bool': 1,
|
||
'utf8': 50, 'string': 50, 'str': 50,
|
||
'categorical': 8, 'cat': 8,
|
||
'date': 4, 'datetime': 8, 'time': 8, 'duration': 8,
|
||
}
|
||
bytes_per_row += size_map.get(numeric_part, 8)
|
||
memory_mb = (bytes_per_row * total_row_count) / (1024 * 1024)
|
||
|
||
# Apply head limit for preview (keeps LazyFrame lazy — rows are limited on collect)
|
||
if head is not None:
|
||
merged_lf = merged_lf.head(head)
|
||
total_row_count = min(total_row_count, head)
|
||
|
||
return merged_lf, merged_schema, total_row_count, len(paths), memory_mb
|