"""Unit tests for file_utils module.""" import csv import gzip import os import tempfile from pathlib import Path from unittest.mock import patch import pyarrow as pa import pyarrow.parquet as pq import pytest from src.enums import FileType from src.errors import InvalidFileTypeError, TransientError from src.utils.file_utils import ( compute_md5, create_temp_file, detect_encoding, get_dir_path, get_file_metadata, get_file_type, get_first_n_lines, get_num_cpu_workers, get_num_io_workers, is_csv_file, is_gzip_file, is_parquet_file, is_simple_etag, is_xlsx_file, try_disk_space, ) class TestWorkerCount: """Tests for worker count functions.""" @patch('src.utils.file_utils.os.cpu_count') @patch('src.utils.file_utils.os.sched_getaffinity', create=True) def test_get_num_workers_for_cpu_tasks_none( self, mock_sched_getaffinity, mock_cpu_count ): """Test get_num_workers_for_cpu_tasks when cpu_count returns None.""" mock_sched_getaffinity.side_effect = AttributeError mock_cpu_count.return_value = None assert get_num_cpu_workers() == 1 @patch('src.utils.file_utils.os.cpu_count') @patch('src.utils.file_utils.os.sched_getaffinity', create=True) def test_get_num_workers_for_io_tasks_none( self, mock_sched_getaffinity, mock_cpu_count ): """Test get_num_workers_for_io_tasks when cpu_count returns None.""" from config import config mock_sched_getaffinity.side_effect = AttributeError mock_cpu_count.return_value = None expected = config.exec.DEFAULT_CPU_COUNT * config.exec.IO_THREADS_PER_VCPU assert get_num_io_workers() == expected class TestGetDirPath: """Tests for get_dir_path function.""" def test_get_dir_path_from_file(self): """Test get_dir_path returns directory from file path.""" with tempfile.NamedTemporaryFile(delete=False) as f: file_path = f.name try: result = get_dir_path(file_path) assert result == os.path.dirname(os.path.realpath(file_path)) assert os.path.isdir(result) finally: if os.path.exists(file_path): os.remove(file_path) def test_get_dir_path_from_directory(self): """Test get_dir_path returns directory as-is.""" with tempfile.TemporaryDirectory() as tmpdir: result = get_dir_path(tmpdir) assert result == os.path.realpath(tmpdir) assert os.path.isdir(result) class TestCreateTempFile: """Tests for create_temp_file function.""" def test_create_temp_file_default(self): """Test create_temp_file with default directory.""" file_path = create_temp_file() try: assert os.path.exists(file_path) # Verify secure permissions stat_info = os.stat(file_path) mode = stat_info.st_mode & 0o777 assert mode == 0o600 finally: if os.path.exists(file_path): os.remove(file_path) def test_create_temp_file_with_ref_path(self): """Test create_temp_file with reference path.""" with tempfile.TemporaryDirectory() as tmpdir: ref_file = os.path.join(tmpdir, 'ref.txt') with open(ref_file, 'w') as f: f.write('test') file_path = create_temp_file(ref_file) try: assert os.path.exists(file_path) assert os.path.realpath(os.path.dirname(file_path)) == os.path.realpath( tmpdir ) finally: if os.path.exists(file_path): os.remove(file_path) class TestIsGzipFile: """Tests for is_gzip_file function.""" def test_is_gzip_file_true(self): """Test is_gzip_file returns True for gzipped file.""" with tempfile.NamedTemporaryFile(mode='wb', suffix='.gz', delete=False) as f: with gzip.open(f.name, 'wt') as gz: gz.write('test content') gz_path = f.name try: assert is_gzip_file(gz_path) is True finally: if os.path.exists(gz_path): os.remove(gz_path) def test_is_gzip_file_false(self): """Test is_gzip_file returns False for non-gzipped file.""" with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: f.write('test content') file_path = f.name try: assert is_gzip_file(file_path) is False finally: if os.path.exists(file_path): os.remove(file_path) class TestIsCsvFile: """Tests for is_csv_file function.""" def test_is_csv_file_true(self): """Test is_csv_file returns True for valid CSV.""" with tempfile.NamedTemporaryFile( mode='w', suffix='.csv', delete=False, encoding='utf-8' ) as f: writer = csv.writer(f) writer.writerow(['col1', 'col2']) writer.writerow(['val1', 'val2']) csv_path = f.name try: assert is_csv_file(csv_path, 'utf-8', gzipped=False) is True finally: if os.path.exists(csv_path): os.remove(csv_path) def test_is_csv_file_false_for_binary(self): """Test is_csv_file returns False for binary file.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'\x00\x01\x02\x03\x04\x05') bin_path = f.name try: assert is_csv_file(bin_path, 'utf-8', gzipped=False) is False finally: if os.path.exists(bin_path): os.remove(bin_path) def test_is_csv_file_gzipped(self): """Test is_csv_file with gzipped CSV.""" with tempfile.NamedTemporaryFile( mode='wb', suffix='.csv.gz', delete=False ) as f: gz_path = f.name with gzip.open(gz_path, 'wt', encoding='utf-8') as gz: writer = csv.writer(gz) writer.writerow(['col1', 'col2']) writer.writerow(['val1', 'val2']) try: assert is_csv_file(gz_path, 'utf-8', gzipped=True) is True finally: if os.path.exists(gz_path): os.remove(gz_path) class TestIsParquetFile: """Tests for is_parquet_file function.""" def test_is_parquet_file_true(self): """Test is_parquet_file returns True for valid parquet.""" with tempfile.NamedTemporaryFile( mode='wb', suffix='.parquet', delete=False ) as f: pq_path = f.name table = pa.table({'col1': ['val1', 'val2'], 'col2': ['val3', 'val4']}) pq.write_table(table, pq_path) try: assert is_parquet_file(pq_path, gzipped=False) is True finally: if os.path.exists(pq_path): os.remove(pq_path) def test_is_parquet_file_false(self): """Test is_parquet_file returns False for non-parquet file.""" with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: f.write('not a parquet file') file_path = f.name try: assert is_parquet_file(file_path, gzipped=False) is False finally: if os.path.exists(file_path): os.remove(file_path) class TestIsXlsxFile: """Tests for is_xlsx_file function.""" def test_is_xlsx_file_true(self): """Test is_xlsx_file returns True for XLSX signature.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'\x50\x4b\x03\x04') # ZIP/XLSX magic numbers file_path = f.name try: assert is_xlsx_file(file_path, gzipped=False) is True finally: if os.path.exists(file_path): os.remove(file_path) def test_is_xlsx_file_false(self): """Test is_xlsx_file returns False for non-XLSX file.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'not an xlsx file') file_path = f.name try: assert is_xlsx_file(file_path, gzipped=False) is False finally: if os.path.exists(file_path): os.remove(file_path) class TestDetectEncoding: """Tests for detect_encoding function.""" def test_detect_encoding_utf8(self): """Test detect_encoding detects UTF-8 or ASCII.""" with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f: f.write('This is a test file with UTF-8 encoding.') file_path = f.name try: encoding = detect_encoding(file_path, gzipped=False) assert encoding is not None # ASCII is a valid subset of UTF-8, so both are acceptable assert encoding.lower() in ['utf-8', 'ascii', 'utf_8'] finally: if os.path.exists(file_path): os.remove(file_path) def test_detect_encoding_latin1(self): """Test detect_encoding detects Latin-1.""" with tempfile.NamedTemporaryFile( mode='w', delete=False, encoding='latin-1' ) as f: f.write('This is a test file with Latin-1 encoding.') file_path = f.name try: encoding = detect_encoding(file_path, gzipped=False) assert encoding is not None finally: if os.path.exists(file_path): os.remove(file_path) class TestGetFileType: """Tests for get_file_type function.""" def test_get_file_type_csv(self): """Test get_file_type detects CSV.""" with tempfile.NamedTemporaryFile( mode='w', suffix='.csv', delete=False, encoding='utf-8' ) as f: writer = csv.writer(f) writer.writerow(['col1', 'col2']) writer.writerow(['val1', 'val2']) csv_path = f.name try: file_type = get_file_type(csv_path, 'utf-8', gzipped=False) assert file_type == FileType.CSV finally: if os.path.exists(csv_path): os.remove(csv_path) def test_get_file_type_xlsx(self): """Test get_file_type detects XLSX.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'\x50\x4b\x03\x04') # ZIP/XLSX magic numbers file_path = f.name try: file_type = get_file_type(file_path, 'utf-8', gzipped=False) assert file_type == FileType.XLSX finally: if os.path.exists(file_path): os.remove(file_path) def test_get_file_type_parquet(self): """Test get_file_type detects Parquet.""" with tempfile.NamedTemporaryFile( mode='wb', suffix='.parquet', delete=False ) as f: pq_path = f.name table = pa.table({'col1': ['val1', 'val2']}) pq.write_table(table, pq_path) try: file_type = get_file_type(pq_path, 'utf-8', gzipped=False) assert file_type == FileType.PQT finally: if os.path.exists(pq_path): os.remove(pq_path) def test_get_file_type_invalid(self): """Test get_file_type raises error for unsupported type.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'\x00\x01\x02\x03\x04\x05') file_path = f.name try: with pytest.raises(InvalidFileTypeError, match='Unsupported file type'): get_file_type(file_path, 'utf-8', gzipped=False) finally: if os.path.exists(file_path): os.remove(file_path) class TestGetFileMetadata: """Tests for get_file_metadata function.""" def test_get_file_metadata_csv(self): """Test get_file_metadata for CSV file.""" with tempfile.NamedTemporaryFile( mode='w', suffix='.csv', delete=False, encoding='utf-8' ) as f: writer = csv.writer(f) writer.writerow(['col1', 'col2']) writer.writerow(['val1', 'val2']) csv_path = f.name try: metadata = get_file_metadata(csv_path) assert metadata.file_path == Path(csv_path) assert metadata.encoding is not None assert metadata.file_type == FileType.CSV assert metadata.gzipped is False finally: if os.path.exists(csv_path): os.remove(csv_path) class TestGetFirstNLines: """Tests for get_first_n_lines function.""" def test_get_first_n_lines_basic(self): """Test get_first_n_lines reads correct number of lines.""" with tempfile.NamedTemporaryFile(mode='w', delete=False, encoding='utf-8') as f: f.write('line1\nline2\nline3\nline4\nline5\n') file_path = f.name try: result = get_first_n_lines(file_path, 3, gzipped=False) lines = result.split(b'\n') assert len([line for line in lines if line]) == 3 finally: if os.path.exists(file_path): os.remove(file_path) def test_get_first_n_lines_invalid_n(self): """Test get_first_n_lines raises error for invalid n.""" with pytest.raises(ValueError, match='n must be positive'): get_first_n_lines('/tmp/test.txt', 0, gzipped=False) def test_get_first_n_lines_gzipped(self): """Test get_first_n_lines with gzipped file.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: gz_path = f.name with gzip.open(gz_path, 'wt', encoding='utf-8') as gz: gz.write('line1\nline2\nline3\n') try: result = get_first_n_lines(gz_path, 2, gzipped=True) lines = result.split(b'\n') assert len([line for line in lines if line]) == 2 finally: if os.path.exists(gz_path): os.remove(gz_path) class TestTryDiskSpace: """Tests for try_disk_space function.""" def test_try_disk_space_sufficient(self): """Test try_disk_space passes with sufficient space.""" try_disk_space(1024, path='/tmp') def test_try_disk_space_insufficient(self): """Test try_disk_space raises error with insufficient space.""" with pytest.raises(TransientError, match='Insufficient disk space'): try_disk_space(999999999999999999, path='/tmp') @patch('src.utils.file_utils.os.statvfs') def test_try_disk_space_os_error(self, mock_statvfs): """Test try_disk_space raises TransientError on OS error.""" mock_statvfs.side_effect = OSError('Permission denied') with pytest.raises(TransientError, match='Failed to check disk space'): try_disk_space(1024, path='/tmp') class TestGzippedFileDetection: """Tests for gzipped file detection functions.""" def test_is_parquet_file_gzipped(self): """Test is_parquet_file with gzipped parquet file.""" import pyarrow as pa import pyarrow.parquet as pq from src.utils.file_utils import is_parquet_file # Create a temporary parquet file and gzip it with tempfile.NamedTemporaryFile(suffix='.parquet', delete=False) as f: temp_parquet = f.name with tempfile.NamedTemporaryFile(suffix='.parquet.gz', delete=False) as f: temp_gzipped = f.name try: # Create a simple parquet file table = pa.table({'col1': [1, 2, 3]}) pq.write_table(table, temp_parquet) # Gzip it with open(temp_parquet, 'rb') as f_in: with gzip.open(temp_gzipped, 'wb') as f_out: f_out.write(f_in.read()) # Test assert is_parquet_file(temp_gzipped, gzipped=True) is True finally: for path in [temp_parquet, temp_gzipped]: if os.path.exists(path): os.remove(path) def test_is_xlsx_file_gzipped(self): """Test is_xlsx_file with gzipped xlsx file.""" from src.utils.file_utils import is_xlsx_file # Create a temporary file with ZIP/XLSX signature and gzip it with tempfile.NamedTemporaryFile(suffix='.xlsx.gz', delete=False) as f: temp_gzipped = f.name try: # Write ZIP signature (used by XLSX) and gzip it with gzip.open(temp_gzipped, 'wb') as f: f.write(b'\x50\x4b\x03\x04') # ZIP/XLSX signature # Test assert is_xlsx_file(temp_gzipped, gzipped=True) is True finally: if os.path.exists(temp_gzipped): os.remove(temp_gzipped) def test_is_xlsx_file_gzipped_bad_gzip(self): """Test is_xlsx_file handles BadGzipFile exception.""" from src.utils.file_utils import is_xlsx_file # Create a file that claims to be gzipped but isn't with tempfile.NamedTemporaryFile(suffix='.xlsx.gz', delete=False) as f: f.write(b'not a gzip file') temp_file = f.name try: # Should return False, not raise exception assert is_xlsx_file(temp_file, gzipped=True) is False finally: if os.path.exists(temp_file): os.remove(temp_file) class TestComputeMd5: """Tests for compute_md5 function.""" def test_compute_md5_basic(self): """Test compute_md5 computes correct checksum.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'test content') file_path = f.name try: md5 = compute_md5(file_path) assert isinstance(md5, str) assert len(md5) == 32 # MD5 of 'test content' assert md5 == '9473fdd0d880a43c21b7778d34872157' finally: if os.path.exists(file_path): os.remove(file_path) def test_compute_md5_empty_file(self): """Test compute_md5 handles empty file.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: file_path = f.name try: md5 = compute_md5(file_path) assert isinstance(md5, str) assert len(md5) == 32 assert md5 == 'd41d8cd98f00b204e9800998ecf8427e' finally: if os.path.exists(file_path): os.remove(file_path) def test_compute_md5_large_file(self): """Test compute_md5 handles large file with chunking.""" with tempfile.NamedTemporaryFile(mode='wb', delete=False) as f: f.write(b'x' * 1024 * 1024) # 1 MB file_path = f.name try: md5 = compute_md5(file_path) assert isinstance(md5, str) assert len(md5) == 32 finally: if os.path.exists(file_path): os.remove(file_path) class TestIsSimpleEtag: """Tests for is_simple_etag function.""" def test_is_simple_etag_true(self): """Test is_simple_etag returns True for simple MD5 ETag.""" assert is_simple_etag('abc123def456') is True assert is_simple_etag('d41d8cd98f00b204e9800998ecf8427e') is True def test_is_simple_etag_false(self): """Test is_simple_etag returns False for multipart ETag.""" assert is_simple_etag('abc123-2') is False assert is_simple_etag('def456-5') is False assert is_simple_etag('hash-123') is False