"""Tests for analysis.data_loader — BOM detection, schema validation, load/config.""" import csv import os import tempfile from pathlib import Path import pytest import polars as pl from analysis.data_loader import ( _file_count, _merge_schema_entries, _resolve_encoding, detect_bom, load_config, load_csv_directory, validate_schemas, ) # ═══════════════════════════════════════════════════════════════════════ # BOM detection # ═══════════════════════════════════════════════════════════════════════ class TestDetectBOM: """detect_bom() should return the correct encoding string for each BOM.""" def test_utf8_bom(self): """UTF-8 BOM (EF BB BF) → 'utf-8-sig'.""" path = _write_bytes(b'\xef\xbb\xbfcol1,col2\n1,2\n') try: assert detect_bom(path) == 'utf-8-sig' finally: os.unlink(path) def test_utf16le_bom(self): """UTF-16 LE BOM (FF FE) → 'utf-16le'.""" path = _write_bytes(b'\xff\xfec\x00o\x00l\x001\x00,\x00c\x00o\x00l\x002\x00\n\x001\x00,\x002\x00\n') try: assert detect_bom(path) == 'utf-16le' finally: os.unlink(path) def test_utf16be_bom(self): """UTF-16 BE BOM (FE FF) → 'utf-16be'.""" path = _write_bytes(b'\xfe\xff\x00c\x00o\x00l\x001\x00,\x00c\x00o\x00l\x002\x00\n\x001\x00,\x002\x00\n') try: assert detect_bom(path) == 'utf-16be' finally: os.unlink(path) def test_no_bom(self): """Plain ASCII/UTF-8 without BOM → 'utf-8'.""" path = _write_bytes(b'col1,col2\n1,2\n') try: assert detect_bom(path) == 'utf-8' finally: os.unlink(path) def test_empty_file(self): """Empty file → 'utf-8'.""" path = _write_bytes(b'') try: assert detect_bom(path) == 'utf-8' finally: os.unlink(path) # ═══════════════════════════════════════════════════════════════════════ # Schema validation # ═══════════════════════════════════════════════════════════════════════ class TestValidateSchemas: """validate_schemas() must raise ValueError when column sets differ.""" def test_matching_schemas(self): """Identical schemas → no exception.""" schemas = [ {':ips': 'Utf8', '8ack': 'Int64'}, {':ips': 'Utf8', '8ack': 'Int64'}, ] validate_schemas(schemas) # should not raise def test_empty_list(self): """Empty list → no exception.""" validate_schemas([]) def test_schema_mismatch_missing_column(self): """One file missing a column → ValueError.""" schemas = [ {':ips': 'Utf8', '8ack': 'Int64'}, {':ips': 'Utf8'}, # missing '8ack' ] with pytest.raises(ValueError, match=r'missing columns'): validate_schemas(schemas) def test_schema_mismatch_extra_column(self): """One file has an extra column → ValueError.""" schemas = [ {':ips': 'Utf8'}, {':ips': 'Utf8', 'extra_col': 'Int64'}, ] with pytest.raises(ValueError, match=r'extra columns'): validate_schemas(schemas) def test_schema_mismatch_both_sides(self): """Both missing and extra columns → ValueError with both messages.""" schemas = [ {'a': 'Int64', 'b': 'Utf8'}, {'a': 'Int64', 'c': 'Float64'}, ] with pytest.raises(ValueError): validate_schemas(schemas) class TestMergeSchemaEntries: """_merge_schema_entries() should merge without raising.""" def test_simple_merge(self): merged, conflicts = _merge_schema_entries([ {'a': 'Int64', 'b': 'Utf8'}, {'a': 'Int64', 'c': 'Float64'}, ]) assert set(merged.keys()) == {'a', 'b', 'c'} assert merged['a'] == 'Int64' def test_type_conflict(self): """Different dtypes for same column → last wins, conflict recorded.""" merged, conflicts = _merge_schema_entries([ {'val': 'Int64'}, {'val': 'Float64'}, ]) assert merged['val'] == 'Float64' assert 'val' in conflicts # ═══════════════════════════════════════════════════════════════════════ # CSV loading # ═══════════════════════════════════════════════════════════════════════ class TestLoadCSVDirectory: """End-to-end load_csv_directory() tests with temporary CSV files.""" def test_basic_load(self): """Single CSV → LazyFrame + correct schema + row/file counts.""" with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / 'test.csv' _write_csv(path, [ [':ips', ':ipd', '8ack'], ['10.0.0.1', '10.0.0.2', '100'], ['10.0.0.1', '10.0.0.3', '200'], ['10.0.0.2', '10.0.0.4', '150'], ]) lf, schema, row_count, file_count, memory_mb = load_csv_directory( str(path), encoding='utf-8' ) assert isinstance(lf, pl.LazyFrame) assert ':ips' in schema assert ':ipd' in schema assert '8ack' in schema assert file_count == 1 assert row_count > 0 assert memory_mb >= 0 # Verify data round-trip df = lf.collect() assert len(df) == 3 assert set(df[':ips'].to_list()) == {'10.0.0.1', '10.0.0.2'} def test_multiple_files(self): """Multiple CSVs with same schema → merged LazyFrame.""" with tempfile.TemporaryDirectory() as tmpdir: p1 = Path(tmpdir) / 'part1.csv' p2 = Path(tmpdir) / 'part2.csv' _write_csv(p1, [['x'], ['1'], ['2']]) _write_csv(p2, [['x'], ['3'], ['4']]) glob_pattern = str(Path(tmpdir) / 'part*.csv') lf, schema, row_count, file_count, memory_mb = load_csv_directory( glob_pattern, encoding='utf-8' ) assert file_count == 2 df = lf.collect() assert len(df) == 4 assert sorted(df['x'].to_list()) == [1, 2, 3, 4] def test_no_files(self): """No matching files → FileNotFoundError.""" with pytest.raises(FileNotFoundError, match=r'No files match'): load_csv_directory('__nonexistent_glarg_*.csv') def test_schema_mismatch_across_files_strict(self): """Files with different column sets + schema_strict=True → ValueError.""" with tempfile.TemporaryDirectory() as tmpdir: _write_csv(Path(tmpdir) / 'a.csv', [['x', 'y'], ['1', '2']]) _write_csv(Path(tmpdir) / 'b.csv', [['x'], ['3']]) with pytest.raises(ValueError, match=r'Schema mismatch'): load_csv_directory(str(Path(tmpdir) / '*.csv'), schema_strict=True) def test_schema_mismatch_across_files_lenient(self): """Files with different column sets + schema_strict=False → merge with nulls.""" with tempfile.TemporaryDirectory() as tmpdir: _write_csv(Path(tmpdir) / 'a.csv', [['x', 'y'], ['1', '2']]) _write_csv(Path(tmpdir) / 'b.csv', [['x'], ['3']]) lf, schema, *_ = load_csv_directory(str(Path(tmpdir) / '*.csv')) df = lf.collect() assert len(df) == 2 assert 'x' in df.columns assert 'y' in df.columns # Row from b.csv should have null for 'y' assert df['y'].is_null().sum() == 1 def test_schema_override(self): """schema_override forces a specific column dtype.""" with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / 'test.csv' _write_csv(path, [['val'], ['123'], ['456']]) lf, schema, *_ = load_csv_directory( str(path), encoding='utf-8', schema_override={'val': 'Float64'}, ) df = lf.collect() assert df['val'].dtype == pl.Float64 def test_custom_delimiter(self): """Tab-delimited file should parse correctly.""" with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / 'test.tsv' with open(path, 'w', newline='') as f: f.write('a\tb\n1\t2\n3\t4\n') lf, *_ = load_csv_directory(str(path), delimiter='\t') df = lf.collect() assert len(df) == 2 assert df.columns == ['a', 'b'] def test_glob_with_subdirectory(self): """Recursive glob should find files in subdirectories.""" with tempfile.TemporaryDirectory() as tmpdir: sub = Path(tmpdir) / 'sub' sub.mkdir() _write_csv(sub / 'nested.csv', [['x'], ['42']]) lf, schema, row_count, file_count, memory_mb = load_csv_directory( str(Path(tmpdir) / '**' / '*.csv') ) assert file_count == 1 assert row_count > 0 def test_latlon_with_plus_and_blank(self): """CSV with lat/lon columns containing '+', '' loads without error and the column is Float64 after cleaning.""" with tempfile.TemporaryDirectory() as tmpdir: path = Path(tmpdir) / 'latlon_test.csv' with open(path, 'w', newline='') as f: f.write('src_ip,latitude,longitude\n') f.write('10.0.0.1,+,120.0\n') f.write('10.0.0.2,35.5,\n') f.write('10.0.0.3,-,\n') f.write('10.0.0.4,40.0,121.5\n') lf, schema, *_ = load_csv_directory(str(path)) df = lf.collect() # lat/lon columns should be Float64 in schema assert 'latitude' in schema assert 'longitude' in schema assert 'latitude' in df.columns assert 'longitude' in df.columns # Check dtypes are Float64 assert df['latitude'].dtype == pl.Float64 assert df['longitude'].dtype == pl.Float64 # Standalone '+' and blank should become null assert df['latitude'].is_null().sum() == 2 # rows 0,2 assert df['longitude'].is_null().sum() == 2 # rows 1,2 # Valid values survive assert df['latitude'].drop_nulls().to_list() == [35.5, 40.0] assert df['longitude'].drop_nulls().to_list() == [120.0, 121.5] # ═══════════════════════════════════════════════════════════════════════ # Config loading # ═══════════════════════════════════════════════════════════════════════ class TestLoadConfig: """load_config() YAML loading and error handling.""" def test_load_valid_yaml(self): path = _write_yaml('key: value\nnested:\n inner: 42\n') try: config = load_config(path) assert config['key'] == 'value' assert config['nested']['inner'] == 42 finally: os.unlink(path) def test_load_empty_yaml(self): path = _write_yaml('') try: config = load_config(path) assert config == {} finally: os.unlink(path) def test_config_path_none(self): assert load_config(None) == {} def test_config_not_found(self): with pytest.raises(FileNotFoundError): load_config('/__nonexistent__/config.yaml') # ═══════════════════════════════════════════════════════════════════════ # Utility functions # ═══════════════════════════════════════════════════════════════════════ class TestResolveEncoding: def test_bom_overrides_default(self): assert _resolve_encoding('utf-8', 'utf-16le') == 'utf-16le' def test_no_bom_uses_default(self): # Polars >= 1.0 uses 'utf8' (not 'utf-8') assert _resolve_encoding('utf-8', 'utf-8') == 'utf8' assert _resolve_encoding('latin-1', 'utf-8') == 'latin-1' class TestFileCount: def test_count(self): assert _file_count(['a', 'b', 'c']) == 3 assert _file_count([]) == 0 # ═══════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════ def _write_csv(path: Path, rows: list[list[str]]) -> None: """Write rows as a CSV file.""" with open(path, 'w', newline='') as f: writer = csv.writer(f) for row in rows: writer.writerow(row) def _write_bytes(data: bytes) -> str: """Write raw bytes to a temp file; return the path string.""" with tempfile.NamedTemporaryFile(suffix='.csv', delete=False) as f: f.write(data) return f.name def _write_yaml(content: str) -> str: """Write YAML string to a temp file; return the path string.""" with tempfile.NamedTemporaryFile(suffix='.yaml', mode='w', delete=False) as f: f.write(content) return f.name