Là một data engineer làm việc với dữ liệu thời gian thực, tôi đã từng đối mặt với vô số lỗi khi xử lý data pipeline. Một trong những lỗi đáng nhớ nhất là khi tôi cố gắng tải 50GB dữ liệu từ Tardis API và nhận được liên tục các thông báo lỗi: ConnectionError: timeout after 30000ms, tiếp followed by 429 Too Many Requests. Sau 3 ngày debug, tôi mới tìm ra root cause — không phải do API có vấn đề, mà do cách tôi xử lý data streaming hoàn toàn sai. Bài viết này sẽ chia sẻ những kinh nghiệm thực chiến để bạn tránh lặp lại những sai lầm tương tự.
Tardis API là gì và tại sao nên dùng Parquet?
Tardis API là một dịch vụ cung cấp dữ liệu thị trường tài chính, crypto và các sự kiện kinh tế với độ trễ thấp. Parquet là định dạng columnar storage được thiết kế đặc biệt cho phân tích dữ liệu lớn, có khả năng nén cao (thường đạt 2-4x so với CSV) và hỗ trợ predicate pushdown giúp giảm đáng kể thời gian đọc dữ liệu.
Cài đặt môi trường và dependencies
Trước khi bắt đầu, hãy đảm bảo bạn đã cài đặt các thư viện cần thiết. Tôi khuyến nghị sử dụng Python 3.10+ với virtual environment để tránh xung đột dependency.
# Tạo virtual environment
python -m venv tardis-env
source tardis-env/bin/activate # Linux/Mac
tardis-env\Scripts\activate # Windows
Cài đặt dependencies
pip install requests pandas pyarrow duckdb httpx aiohttp \
python-dotenv s3fs pyarrow parquet-tools
Kiểm tra phiên bản
python -c "import duckdb; print(f'DuckDB version: {duckdb.__version__}')"
Output: DuckDB version: 0.9.2
Tải dữ liệu Parquet từ Tardis API
Method 1: Synchronous Download với Retry Logic
import requests
import pandas as pd
import time
from pathlib import Path
from typing import Optional, List
class TardisParquetDownloader:
"""Downloader với retry logic và progress tracking"""
BASE_URL = "https://api.tardis.io/v1"
CHUNK_SIZE = 8192
MAX_RETRIES = 5
RETRY_DELAY = 2
def __init__(self, api_key: str, output_dir: str = "./data"):
self.api_key = api_key
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.session = requests.Session()
self.session.headers.update({
"Authorization": f"Bearer {api_key}",
"Accept": "application/x-parquet",
"User-Agent": "TardisClient/2.1.0"
})
def _make_request(self, url: str, params: dict = None) -> requests.Response:
"""Gửi request với exponential backoff retry"""
for attempt in range(self.MAX_RETRIES):
try:
response = self.session.get(
url,
params=params,
timeout=(10, 60), # (connect, read) timeout
stream=True
)
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
wait_time = self.RETRY_DELAY * (2 ** attempt)
print(f"Attempt {attempt + 1} failed: {e}")
if attempt < self.MAX_RETRIES - 1:
print(f"Retrying in {wait_time}s...")
time.sleep(wait_time)
else:
raise
raise Exception("Max retries exceeded")
def download_parquet(
self,
dataset: str,
symbols: List[str],
start_date: str,
end_date: str,
filters: Optional[dict] = None
) -> Path:
"""Tải dữ liệu Parquet với filtering"""
url = f"{self.BASE_URL}/download/parquet"
params = {
"dataset": dataset,
"symbols": ",".join(symbols),
"start": start_date,
"end": end_date,
"compression": "snappy",
"useDeprecatedFormats": "false"
}
if filters:
params.update(filters)
filename = f"{dataset}_{start_date}_{end_date}.parquet"
filepath = self.output_dir / filename
print(f"Downloading {filename}...")
response = self._make_request(url, params)
total_size = int(response.headers.get("content-length", 0))
downloaded = 0
with open(filepath, "wb") as f:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
if chunk:
f.write(chunk)
downloaded += len(chunk)
if total_size:
progress = (downloaded / total_size) * 100
print(f"\rProgress: {progress:.1f}%", end="")
print(f"\nSaved to {filepath}")
return filepath
def download_and_convert(
self,
dataset: str,
symbols: List[str],
start_date: str,
end_date: str
) -> pd.DataFrame:
"""Tải và chuyển đổi sang DataFrame"""
filepath = self.download_parquet(dataset, symbols, start_date, end_date)
df = pd.read_parquet(filepath)
print(f"Loaded {len(df):,} rows, {df.memory_usage(deep=True).sum() / 1024**2:.1f} MB")
return df
Sử dụng
if __name__ == "__main__":
downloader = TardisParquetDownloader(
api_key="YOUR_TARDIS_API_KEY",
output_dir="./market_data"
)
df = downloader.download_and_convert(
dataset="crypto_ohlcv",
symbols=["BTC-USD", "ETH-USD"],
start_date="2024-01-01",
end_date="2024-03-01"
)
print(df.head())
Method 2: Async Download với aiohttp cho high throughput
import asyncio
import aiohttp
import aiofiles
from dataclasses import dataclass
from typing import List, Optional
import json
@dataclass
class DownloadTask:
url: str
filepath: str
expected_size: Optional[int] = None
class AsyncTardisDownloader:
"""Async downloader cho batch processing với concurrency control"""
BASE_URL = "https://api.tardis.io/v1"
MAX_CONCURRENT = 5
CHUNK_SIZE = 65536
def __init__(self, api_key: str, output_dir: str = "./data"):
self.api_key = api_key
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.semaphore = asyncio.Semaphore(self.MAX_CONCURRENT)
def _get_headers(self) -> dict:
return {
"Authorization": f"Bearer {self.api_key}",
"Accept": "application/x-parquet"
}
async def _download_file(
self,
session: aiohttp.ClientSession,
task: DownloadTask
) -> dict:
"""Download một file với progress"""
async with self.semaphore:
result = {
"url": task.url,
"filepath": task.filepath,
"status": "pending",
"size": 0,
"error": None
}
try:
async with session.get(task.url, headers=self._get_headers()) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
result["expected_size"] = total_size
async with aiofiles.open(task.filepath, "wb") as f:
downloaded = 0
async for chunk in response.content.iter_chunked(self.CHUNK_SIZE):
await f.write(chunk)
downloaded += len(chunk)
if total_size and downloaded % (1024 * 1024) == 0:
progress = (downloaded / total_size) * 100
print(f"[{task.filepath}] {progress:.0f}%")
result["status"] = "success"
result["size"] = downloaded
except Exception as e:
result["status"] = "error"
result["error"] = str(e)
return result
async def batch_download(self, tasks: List[DownloadTask]) -> List[dict]:
"""Download nhiều file đồng thời"""
connector = aiohttp.TCPConnector(limit=self.MAX_CONCURRENT)
timeout = aiohttp.ClientTimeout(total=3600)
async with aiohttp.ClientSession(
connector=connector,
timeout=timeout
) as session:
results = await asyncio.gather(
*[self._download_file(session, task) for task in tasks],
return_exceptions=True
)
valid_results = []
for i, result in enumerate(results):
if isinstance(result, Exception):
valid_results.append({
"url": tasks[i].url,
"status": "error",
"error": str(result)
})
else:
valid_results.append(result)
return valid_results
async def download_with_filters(
self,
symbols: List[str],
start_date: str,
end_date: str,
interval: str = "1h"
) -> List[Path]:
"""Tạo và download multiple files dựa trên symbols"""
tasks = []
for symbol in symbols:
url = (
f"{self.BASE_URL}/download/parquet"
f"?symbol={symbol}&start={start_date}&end={end_date}"
f"&interval={interval}&compression=snappy"
)
filename = f"{symbol.replace('-', '_')}_{interval}_{start_date}_{end_date}.parquet"
filepath = str(self.output_dir / filename)
tasks.append(DownloadTask(url=url, filepath=filepath))
results = await self.batch_download(tasks)
successful = [Path(r["filepath"]) for r in results if r["status"] == "success"]
print(f"\nDownloaded {len(successful)}/{len(tasks)} files successfully")
return successful
Chạy async download
async def main():
downloader = AsyncTardisDownloader(
api_key="YOUR_TARDIS_API_KEY",
output_dir="./market_data"
)
symbols = ["BTC-USD", "ETH-USD", "SOL-USD", "DOGE-USD", "XRP-USD"]
files = await downloader.download_with_filters(
symbols=symbols,
start_date="2024-01-01",
end_date="2024-03-01",
interval="1h"
)
return files
asyncio.run(main())
Tối ưu hóa Query với DuckDB
DuckDB là một embedded OLAP database được thiết kế cho analytical workloads. Khi kết hợp với Parquet files từ Tardis API, DuckDB có thể đạt hiệu suất query vượt trội nhờ vectorized execution và predicate pushdown. Dưới đây là các best practices tôi đã áp dụng trong production.
Setup DuckDB với Parquet Virtual Tables
import duckdb
import pandas as pd
from pathlib import Path
from typing import List, Optional
class TardisDuckDBOptimizer:
"""DuckDB optimizer với caching và query optimization"""
def __init__(self, data_dir: str = "./market_data"):
self.data_dir = Path(data_dir)
self.conn = duckdb.connect(database=":memory:") # In-memory database
# Cấu hình DuckDB settings cho performance
self.conn.execute("SET threads TO 8")
self.conn.execute("SET force_index_join TO false")
self.conn.execute("SET enable_progress_bar TO true")
self.conn.execute("SET enable_progress_bar_print TO true")
def register_parquet_files(self, pattern: str = "*.parquet") -> int:
"""Register tất cả Parquet files trong thư mục"""
parquet_files = list(self.data_dir.glob(pattern))
if not parquet_files:
print(f"No Parquet files found matching: {pattern}")
return 0
for f in parquet_files:
table_name = f.stem.replace("-", "_").replace(" ", "_")
# Sử dụng AUTO_DETECT để DuckDB tự infer schema
self.conn.execute(f"""
CREATE VIEW IF NOT EXISTS {table_name} AS
SELECT * FROM read_parquet('{f}', auto_detect=true)
""")
print(f"Registered {len(parquet_files)} Parquet files")
return len(parquet_files)
def explain_query(self, query: str) -> str:
"""Phân tích query execution plan"""
result = self.conn.execute(f"EXPLAIN ANALYZE {query}").fetchdf()
return result.iloc[0, 0]
def execute_analytics(
self,
start_date: str,
end_date: str,
symbols: Optional[List[str]] = None
) -> pd.DataFrame:
"""Thực hiện các phân tích phổ biến"""
symbol_filter = ""
if symbols:
symbol_list = "', '".join(symbols)
symbol_filter = f"AND symbol IN ('{symbol_list}')"
# Query 1: Tính returns và volatility theo ngày
daily_stats_query = f"""
WITH price_data AS (
SELECT
symbol,
timestamp,
close,
LAG(close) OVER (PARTITION BY symbol ORDER BY timestamp) as prev_close
FROM read_parquet('{self.data_dir}/*.parquet')
WHERE timestamp >= '{start_date}'
AND timestamp < '{end_date}'
{symbol_filter}
),
returns AS (
SELECT
symbol,
DATE_TRUNC('day', timestamp) as date,
close,
(close - prev_close) / prev_close * 100 as return_pct
FROM price_data
WHERE prev_close IS NOT NULL
)
SELECT
symbol,
date,
AVG(return_pct) as avg_return,
STDDEV_SAMP(return_pct) as volatility,
MIN(close) as low,
MAX(close) as high,
COUNT(*) as observations
FROM returns
GROUP BY symbol, date
ORDER BY date DESC, symbol
"""
print("Executing daily statistics query...")
result = self.conn.execute(daily_stats_query).fetchdf()
return result
def create_aggregated_parquet(
self,
output_path: str,
aggregation: str = "hourly",
symbols: Optional[List[str]] = None
):
"""Tạo aggregated Parquet file để tăng tốc truy vấn sau"""
symbol_filter = ""
if symbols:
symbol_list = "', '".join(symbols)
symbol_filter = f"WHERE symbol IN ('{symbol_list}')"
time_trunc = {
"hourly": "hour",
"daily": "day",
"weekly": "week"
}.get(aggregation, "day")
create_query = f"""
COPY (
SELECT
symbol,
DATE_TRUNC('{time_trunc}', timestamp) as timestamp,
FIRST(open) as open,
MAX(high) as high,
MIN(low) as low,
LAST(close) as close,
SUM(volume) as volume,
COUNT(*) as bar_count
FROM read_parquet('{self.data_dir}/*.parquet')
{symbol_filter}
GROUP BY symbol, DATE_TRUNC('{time_trunc}', timestamp)
ORDER BY symbol, timestamp
) TO '{output_path}' (FORMAT PARQUET, COMPRESSION 'zstd', ROW_GROUP_SIZE 100000)
"""
print(f"Creating {aggregation} aggregated file...")
self.conn.execute(create_query)
print(f"Saved to {output_path}")
def close(self):
"""Đóng connection"""
self.conn.close()
Sử dụng optimizer
optimizer = TardisDuckDBOptimizer(data_dir="./market_data")
optimizer.register_parquet_files("crypto_*.parquet")
Thực hiện analytics
stats = optimizer.execute_analytics(
start_date="2024-01-01",
end_date="2024-03-01",
symbols=["BTC-USD", "ETH-USD"]
)
print(stats.head(10))
Tạo aggregated file cho production queries
optimizer.create_aggregated_parquet(
output_path="./market_data/btc_eth_hourly.parquet",
aggregation="hourly",
symbols=["BTC-USD", "ETH-USD"]
)
optimizer.close()
Advanced: Parallel Query Execution với Multiple Files
import duckdb
import pandas as pd
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Tuple
import pyarrow.parquet as pq
class ParallelDuckDBQuery:
"""Query execution với parallelism trên nhiều Parquet files"""
def __init__(self, n_workers: int = 4):
self.n_workers = n_workers
self.connections = []
# Tạo nhiều connections cho parallel queries
for _ in range(n_workers):
conn = duckdb.connect(database=":memory:")
conn.execute("SET threads TO 2")
self.connections.append(conn)
def _read_single_file(self, filepath: str) -> pd.DataFrame:
"""Đọc một Parquet file"""
return pd.read_parquet(filepath)
def _process_partition(
self,
files: List[str],
conn_idx: int,
query_template: str
) -> pd.DataFrame:
"""Xử lý một partition của files"""
conn = self.connections[conn_idx]
# Register files vào connection
for i, f in enumerate(files):
table_name = f"t{i}"
conn.execute(f"CREATE TEMP TABLE {table_name} AS SELECT * FROM read_parquet('{f}')")
# Execute query
tables = [f"t{i}" for i in range(len(files))]
combined_query = f"SELECT * FROM ({query_template.format(tables=tables[0])})"
result = conn.execute(combined_query).fetchdf()
# Cleanup
for t in tables:
conn.execute(f"DROP TABLE IF EXISTS {t}")
return result
def parallel_query(
self,
files: List[str],
query_template: str,
combine_func: str = "UNION ALL"
) -> pd.DataFrame:
"""Thực hiện query song song trên nhiều files"""
# Chia files thành partitions
chunk_size = max(1, len(files) // self.n_workers)
partitions = [
files[i:i + chunk_size]
for i in range(0, len(files), chunk_size)
]
print(f"Processing {len(files)} files in {len(partitions)} partitions")
results = []
with ThreadPoolExecutor(max_workers=self.n_workers) as executor:
futures = []
for i, partition in enumerate(partitions):
future = executor.submit(
self._process_partition,
partition,
i % self.n_workers,
query_template
)
futures.append(future)
for future in as_completed(futures):
try:
result = future.result()
results.append(result)
except Exception as e:
print(f"Partition failed: {e}")
# Combine results
if not results:
return pd.DataFrame()
combined = pd.concat(results, ignore_index=True)
print(f"Final result: {len(combined):,} rows")
return combined
def close(self):
for conn in self.connections:
conn.close()
Sử dụng parallel query
files = list(Path("./market_data").glob("*.parquet"))
print(f"Found {len(files)} Parquet files")
parallel = ParallelDuckDBQuery(n_workers=4)
Query tính volume-weighted average price
result = parallel.parallel_query(
files=[str(f) for f in files[:20]], # Test với 20 files đầu
query_template="""
SELECT
symbol,
DATE_TRUNC('hour', timestamp) as hour,
SUM(volume * close) / SUM(volume) as vwap,
SUM(volume) as total_volume
FROM {tables}
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
GROUP BY symbol, hour
ORDER BY hour DESC
"""
)
print(result.head())
parallel.close()
Lỗi thường gặp và cách khắc phục
1. Lỗi "HTTP 401 Unauthorized" hoặc "403 Forbidden"
# Nguyên nhân: API key không hợp lệ hoặc hết hạn
Cách khắc phục:
import os
from pathlib import Path
def validate_api_key():
"""Kiểm tra và validate API key"""
# 1. Kiểm tra environment variable
api_key = os.environ.get("TARDIS_API_KEY")
# 2. Hoặc đọc từ file .env
if not api_key:
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ.get("TARDIS_API_KEY")
# 3. Hoặc sử dụng key file (an toàn hơn cho production)
key_file = Path.home() / ".config" / "tardis" / "api_key"
if not api_key and key_file.exists():
api_key = key_file.read_text().strip()
if not api_key:
raise ValueError("""
Tardis API key không được tìm thấy.
Vui lòng thiết lập qua một trong các cách sau:
1. Environment variable:
export TARDIS_API_KEY='your-key-here'
2. File .env:
TARDIS_API_KEY='your-key-here'
3. Key file:
~/.config/tardis/api_key
Lấy API key tại: https://api.tardis.io/auth
""")
# 4. Validate format (Tardis key thường có prefix 'ts_')
if not api_key.startswith('ts_'):
print(f"Warning: API key format có thể không đúng. "
f"Expected prefix 'ts_', got '{api_key[:5]}...'")
return api_key
Sử dụng
api_key = validate_api_key()
print(f"API key validated: {api_key[:8]}...")
2. Lỗi "ConnectionError: timeout after 30000ms" và "429 Too Many Requests"
# Nguyên nhân: Quá nhiều requests trong thời gian ngắn hoặc network latency cao
Cách khắc phục:
import time
import threading
from collections import deque
from typing import Callable, Any
import functools
class RateLimiter:
"""Token bucket rate limiter với thread safety"""
def __init__(self, requests_per_second: float = 5, burst: int = 10):
self.rate = requests_per_second
self.burst = burst
self.tokens = burst
self.last_update = time.time()
self.lock = threading.Lock()
def acquire(self, blocking: bool = True, timeout: float = 60) -> bool:
"""Acquire a token, optionally blocking"""
deadline = time.time() + timeout
while True:
with self.lock:
# Refill tokens
now = time.time()
elapsed = now - self.last_update
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
self.last_update = now
if self.tokens >= 1:
self.tokens -= 1
return True
if not blocking:
return False
if time.time() >= deadline:
return False
time.sleep(0.1) # Wait before retrying
class RobustAPIClient:
"""Client với retry logic, rate limiting và circuit breaker"""
def __init__(
self,
api_key: str,
requests_per_second: float = 5,
max_retries: int = 5
):
self.api_key = api_key
self.max_retries = max_retries
self.rate_limiter = RateLimiter(requests_per_second=requests_per_second)
# Circuit breaker state
self.failure_count = 0
self.failure_threshold = 5
self.circuit_open = False
self.circuit_open_time = None
self.circuit_reset_timeout = 60 # seconds
# Request history for monitoring
self.request_history = deque(maxlen=1000)
def _should_retry(self, error: Exception, attempt: int) -> bool:
"""Quyết định có nên retry dựa trên error type"""
retryable_codes = {408, 429, 500, 502, 503, 504}
if hasattr(error, 'response'):
status_code = error.response.status_code
if status_code in retryable_codes:
return True
# Retry network errors (timeout, connection)
if isinstance(error, (ConnectionError, TimeoutError)):
return True
return attempt < self.max_retries
def _wait_with_jitter(self, attempt: int):
"""Exponential backoff với jitter"""
base_delay = 2 ** attempt
jitter = random.uniform(0, 1)
delay = min(base_delay * jitter, 60) # Cap at 60 seconds
print(f"Waiting {delay:.1f}s before retry...")
time.sleep(delay)
def request(
self,
method: str,
url: str,
**kwargs
) -> requests.Response:
"""Execute request với rate limiting, retry và circuit breaker"""
# Check circuit breaker
if self.circuit_open:
if time.time() - self.circuit_open_time > self.circuit_reset_timeout:
print("Circuit breaker: resetting")
self.circuit_open = False
self.failure_count = 0
else:
raise Exception("Circuit breaker is OPEN. Too many failures.")
# Acquire rate limit token
if not self.rate_limiter.acquire(timeout=30):
raise Exception("Rate limiter timeout")
attempt = 0
last_error = None
while attempt < self.max_retries:
try:
response = requests.request(
method=method,
url=url,
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=(10, 120),
**kwargs
)
# Check for rate limit response
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 60))
print(f"Rate limited. Waiting {retry_after}s...")
time.sleep(retry_after)
attempt += 1
continue
response.raise_for_status()
# Success - reset circuit breaker
self.failure_count = 0
self.request_history.append({"success": True, "timestamp": time.time()})
return response
except Exception as e:
last_error = e
self.request_history.append({
"success": False,
"error": str(e),
"timestamp": time.time()
})
if self._should_retry(e, attempt):
attempt += 1
self._wait_with_jitter(attempt)
else:
break
# Failed after all retries - open circuit breaker
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.circuit_open = True
self.circuit_open_time = time.time()
print(f"Circuit breaker OPENED after {self.failure_count} failures")
raise last_error
Sử dụng client an toàn
client = RobustAPIClient(
api_key="YOUR_TARDIS_API_KEY",
requests_per_second=5, # 5 requests/second
max_retries=5
)
Thực hiện request
try:
response = client.request("GET", "https://api.tardis.io/v1/symbols")
data = response.json()
except Exception as e:
print(f"Request failed: {e}")
3. Lỗi "ArrowInvalid: Not a Parquet file" hoặc "Invalid: Parquet file size is 0"
# Nguyên nhân: File download không hoàn chỉnh hoặc file bị corrupt
Cách khắc phục:
import pyarrow.parquet as pq
from pathlib import Path
import hashlib
class ParquetValidator:
"""Validate Parquet files trước khi xử lý"""
def __init__(self, min_size_bytes: int = 100):
self.min_size = min_size_bytes
self.validation_results = {}
def validate_file(self, filepath: Path) -> dict:
"""Validate một Parquet file"""
result = {
"path": str(filepath),
"valid": False,
"size": 0,
"row_groups": 0,
"schema": None,
"errors": []
}
# 1. Check file exists và size
if not filepath.exists():
result["errors"].append("File does not exist")
return result
result["size"] = filepath.stat().st_size
if result["size"] < self.min_size:
result["errors"].append(f"File too small: {result['size']} bytes")
return result
# 2. Try read metadata (fast check)
try:
parquet_file = pq.ParquetFile(str(filepath))
result["row_groups"] = parquet_file.metadata.num_row_groups
result["schema"] = str(parquet_file.schema)
result["total_rows"] = parquet_file.metadata.num_rows
# 3. Validate magic bytes
with open(filepath, "rb") as f:
magic = f.read(4)
if magic != b"PAR1":
result["errors"].append(f"Invalid magic bytes: {magic}")
return result
# 4. Quick read test (first row group only)
table = parquet_file.read_row_group(0)
if table.num_rows == 0:
result["errors"].append("No rows in first row group")
result["valid"] = len(result["errors"]) == 0
except Exception as e:
result["errors"].append(f"PyArrow error: {str(e)}")
return result
def validate_directory(self, data_dir: str, pattern: str = "*.parquet") -> dict:
"""Validate tất cả files trong thư mục"""
data_path = Path(data_dir)
files = list(data_path.glob(pattern))
summary = {
"total_files": len(files),
"valid_files": 0,
"invalid_files": 0,
"total_size": 0,
"results": []
}
for f in files:
result = self.validate_file(f)
summary["results"].append(result)
if result["valid"]:
summary["valid_files"] += 1
else:
summary["invalid_files"] += 1
summary["