feat: add import templates

This commit is contained in:
2026-06-23 18:59:42 +08:00
parent 7efef8ae64
commit 6a17c9a336
18 changed files with 1331 additions and 10 deletions

View File

@@ -4,8 +4,9 @@ import argparse
from pathlib import Path
from typing import Optional
from .db import DEFAULT_DB_PATH, connect, init_db, load_sample_data
from .db import DEFAULT_DB_PATH, connect, import_template_file, init_db, load_sample_data
from .indicators import compute_area_scores
from .import_templates import ImportTemplateError, validate_tabular_file
from .reporting import format_scores_table, write_monthly_report
@@ -36,6 +37,38 @@ def build_parser() -> argparse.ArgumentParser:
help="Output Markdown path. Default: reports/monthly-YYYY-MM.md",
)
validate_import_parser = subparsers.add_parser(
"validate-import",
help="Validate a CSV or Excel import file against a named template.",
)
validate_import_parser.add_argument(
"--template",
required=True,
help="Template name, for example area_monthly_metrics.",
)
validate_import_parser.add_argument(
"--file",
type=Path,
required=True,
help="CSV, XLSX, or XLSM file to validate.",
)
import_parser = subparsers.add_parser(
"import-file",
help="Validate and import a CSV or Excel file into the local research database.",
)
import_parser.add_argument(
"--template",
required=True,
help="Template name, for example area_monthly_metrics.",
)
import_parser.add_argument(
"--file",
type=Path,
required=True,
help="CSV, XLSX, or XLSM file to import.",
)
return parser
@@ -66,5 +99,38 @@ def main(argv: Optional[list[str]] = None) -> int:
print(f"Generated report: {path}")
return 0
if args.command == "validate-import":
try:
result = validate_tabular_file(args.template, args.file)
except ImportTemplateError as exc:
print(f"Import validation setup error: {exc}")
return 2
if result.valid:
print(
f"Import file is valid: template={result.template_name}, "
f"rows={result.row_count}"
)
return 0
print(
f"Import file is invalid: template={result.template_name}, "
f"rows={result.row_count}, errors={len(result.errors)}"
)
for error in result.errors:
print(f"row {error.row}, column {error.column}: {error.message}")
return 1
if args.command == "import-file":
try:
count = import_template_file(args.db, args.template, args.file)
except ImportTemplateError as exc:
print(f"Import failed: {exc}")
return 1
print(
f"Imported file: template={args.template}, rows={count}, database={args.db}"
)
return 0
parser.error(f"Unknown command: {args.command}")
return 2

View File

@@ -5,12 +5,20 @@ import sqlite3
from pathlib import Path
from typing import Iterable, Mapping, Union
from .import_templates import ImportTemplateError, load_tabular_rows, validate_import_rows
PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_DB_PATH = PROJECT_ROOT / "data" / "shanghai_housing.sqlite"
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
SAMPLE_DIR = PROJECT_ROOT / "data" / "sample"
SQLITE_IMPORT_TABLES = {
"area_monthly_metrics": "area_monthly_metrics",
"neighborhood_monthly_metrics": "neighborhood_monthly_metrics",
"policy_events": "policy_events",
}
def connect(db_path: Union[Path, str] = DEFAULT_DB_PATH) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
@@ -73,6 +81,44 @@ def load_csv(conn: sqlite3.Connection, table: str, path: Path) -> int:
return len(rows)
def import_template_file(
db_path: Union[Path, str],
template_name: str,
file_path: Path,
) -> int:
table = SQLITE_IMPORT_TABLES.get(template_name)
if table is None:
supported = ", ".join(sorted(SQLITE_IMPORT_TABLES))
raise ImportTemplateError(
f"template '{template_name}' cannot be imported into SQLite; "
f"supported templates: {supported}"
)
rows = load_tabular_rows(file_path)
result = validate_import_rows(template_name, rows)
if not result.valid:
first_error = result.errors[0]
raise ImportTemplateError(
f"validation failed at row {first_error.row}, "
f"column {first_error.column}: {first_error.message}"
)
if not rows:
return 0
init_db(db_path)
with connect(db_path) as conn:
columns = list(rows[0].keys())
placeholders = ", ".join(["?"] * len(columns))
column_sql = ", ".join(columns)
update_sql = ", ".join([f"{column}=excluded.{column}" for column in columns])
sql = (
f"INSERT INTO {table} ({column_sql}) VALUES ({placeholders}) "
f"ON CONFLICT DO UPDATE SET {update_sql}"
)
conn.executemany(sql, [tuple(row[column] for column in columns) for row in rows])
return len(rows)
def fetch_area_metrics(conn: sqlite3.Connection, month: str) -> list[sqlite3.Row]:
return conn.execute(
"""

View File

@@ -0,0 +1,306 @@
from __future__ import annotations
import csv
import json
import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Any, Mapping
from urllib.parse import urlparse
PROJECT_ROOT = Path(__file__).resolve().parents[2]
IMPORT_TEMPLATE_SPEC_PATH = PROJECT_ROOT / "config" / "import_templates.json"
@dataclass(frozen=True)
class ImportValidationError:
row: int
column: str
message: str
@dataclass(frozen=True)
class ImportValidationResult:
template_name: str
row_count: int
valid: bool
errors: list[ImportValidationError]
class ImportTemplateError(ValueError):
pass
def load_import_template_specs(
path: Path = IMPORT_TEMPLATE_SPEC_PATH,
) -> dict[str, dict[str, Any]]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def list_template_names(path: Path = IMPORT_TEMPLATE_SPEC_PATH) -> list[str]:
return sorted(load_import_template_specs(path).keys())
def get_template_spec(
template_name: str,
path: Path = IMPORT_TEMPLATE_SPEC_PATH,
) -> dict[str, Any]:
specs = load_import_template_specs(path)
try:
return specs[template_name]
except KeyError as exc:
valid_names = ", ".join(sorted(specs.keys()))
raise ImportTemplateError(
f"unknown template '{template_name}', expected one of: {valid_names}"
) from exc
def validate_csv_file(
template_name: str,
file_path: Path,
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
) -> ImportValidationResult:
rows = read_csv_rows(file_path)
return validate_import_rows(template_name, rows, spec_path=spec_path)
def validate_tabular_file(
template_name: str,
file_path: Path,
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
) -> ImportValidationResult:
suffix = file_path.suffix.lower()
if suffix == ".csv":
rows = read_csv_rows(file_path)
elif suffix in {".xlsx", ".xlsm"}:
rows = read_excel_rows(file_path)
else:
raise ImportTemplateError("file must be a .csv, .xlsx, or .xlsm document")
return validate_import_rows(template_name, rows, spec_path=spec_path)
def load_tabular_rows(file_path: Path) -> list[dict[str, str]]:
suffix = file_path.suffix.lower()
if suffix == ".csv":
return read_csv_rows(file_path)
if suffix in {".xlsx", ".xlsm"}:
return read_excel_rows(file_path)
raise ImportTemplateError("file must be a .csv, .xlsx, or .xlsm document")
def read_csv_rows(file_path: Path) -> list[dict[str, str]]:
with file_path.open("r", encoding="utf-8-sig", newline="") as handle:
reader = csv.DictReader(handle)
return [dict(row) for row in reader]
def read_excel_rows(file_path: Path) -> list[dict[str, str]]:
try:
from openpyxl import load_workbook
except ImportError as exc:
raise ImportTemplateError(
"Excel validation requires openpyxl; install project dependencies first"
) from exc
workbook = load_workbook(file_path, read_only=True, data_only=True)
worksheet = workbook.active
rows = worksheet.iter_rows(values_only=True)
try:
headers = [str(value).strip() if value is not None else "" for value in next(rows)]
except StopIteration:
return []
records: list[dict[str, str]] = []
for values in rows:
if values is None or all(value is None or str(value).strip() == "" for value in values):
continue
record = {
header: "" if value is None else str(value).strip()
for header, value in zip(headers, values)
if header
}
records.append(record)
return records
def validate_import_rows(
template_name: str,
rows: list[Mapping[str, Any]],
spec_path: Path = IMPORT_TEMPLATE_SPEC_PATH,
) -> ImportValidationResult:
spec = get_template_spec(template_name, spec_path)
columns = spec.get("columns", [])
expected_columns = [column["name"] for column in columns]
required_columns = [
column["name"] for column in columns if bool(column.get("required", False))
]
errors: list[ImportValidationError] = []
seen_columns: set[str] = set()
if rows:
seen_columns = set(rows[0].keys())
missing_headers = [column for column in expected_columns if column not in seen_columns]
unexpected_headers = sorted(seen_columns - set(expected_columns))
for column in missing_headers:
errors.append(ImportValidationError(0, column, "missing required header"))
for column in unexpected_headers:
errors.append(ImportValidationError(0, column, "unexpected header"))
if not rows:
errors.append(ImportValidationError(0, "*", "file has no data rows"))
for index, row in enumerate(rows, start=2):
for column in columns:
name = column["name"]
value = normalize_value(row.get(name))
if name in required_columns and value == "":
errors.append(ImportValidationError(index, name, "value is required"))
continue
if value == "":
continue
errors.extend(validate_value(index, name, value, column))
return ImportValidationResult(
template_name=template_name,
row_count=len(rows),
valid=not errors,
errors=errors,
)
def normalize_value(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def validate_value(
row_index: int,
column_name: str,
value: str,
column: Mapping[str, Any],
) -> list[ImportValidationError]:
errors: list[ImportValidationError] = []
if has_sensitive_text(value):
return [
ImportValidationError(
row_index,
column_name,
"value appears to contain credentials or a sensitive local path",
)
]
column_type = column["type"]
if column_type == "text":
return errors
if column_type == "month":
if not is_valid_month(value):
errors.append(ImportValidationError(row_index, column_name, "must be YYYY-MM"))
return errors
if column_type == "date":
if not is_valid_date(value):
errors.append(ImportValidationError(row_index, column_name, "must be YYYY-MM-DD"))
return errors
if column_type == "url":
if not is_valid_url(value):
errors.append(ImportValidationError(row_index, column_name, "must be http(s) URL"))
return errors
if column_type == "enum":
allowed_values = column.get("allowed_values", [])
if value not in allowed_values:
allowed = ", ".join(allowed_values)
errors.append(
ImportValidationError(row_index, column_name, f"must be one of: {allowed}")
)
return errors
if column_type == "integer":
parsed = parse_integer(value)
if parsed is None:
errors.append(ImportValidationError(row_index, column_name, "must be an integer"))
return errors
errors.extend(validate_numeric_range(row_index, column_name, parsed, column))
return errors
if column_type == "decimal":
parsed = parse_decimal(value)
if parsed is None:
errors.append(ImportValidationError(row_index, column_name, "must be a number"))
return errors
errors.extend(validate_numeric_range(row_index, column_name, parsed, column))
return errors
errors.append(
ImportValidationError(row_index, column_name, f"unknown type '{column_type}'")
)
return errors
def validate_numeric_range(
row_index: int,
column_name: str,
value: float,
column: Mapping[str, Any],
) -> list[ImportValidationError]:
errors: list[ImportValidationError] = []
minimum = column.get("min")
maximum = column.get("max")
if minimum is not None and value < float(minimum):
errors.append(
ImportValidationError(row_index, column_name, f"must be >= {minimum}")
)
if maximum is not None and value > float(maximum):
errors.append(
ImportValidationError(row_index, column_name, f"must be <= {maximum}")
)
return errors
def parse_integer(value: str) -> int | None:
if not re.fullmatch(r"-?\d+", value):
return None
return int(value)
def parse_decimal(value: str) -> float | None:
try:
return float(value)
except ValueError:
return None
def is_valid_month(value: str) -> bool:
if not re.fullmatch(r"\d{4}-\d{2}", value):
return False
month = int(value[5:])
return 1 <= month <= 12
def is_valid_date(value: str) -> bool:
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
return False
try:
date.fromisoformat(value)
except ValueError:
return False
return True
def is_valid_url(value: str) -> bool:
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def has_sensitive_text(value: str) -> bool:
lower = value.lower()
return (
"password" in lower
or "secret" in lower
or "postgres://" in lower
or "database_url" in lower
or "root_" in lower
or value.startswith("/Users/")
or value.startswith("/private/")
)

View File

@@ -60,7 +60,8 @@ CREATE TABLE IF NOT EXISTS policy_events (
title TEXT NOT NULL,
impact_direction INTEGER NOT NULL CHECK (impact_direction BETWEEN -2 AND 2),
notes TEXT DEFAULT '',
source_url TEXT
source_url TEXT,
source TEXT NOT NULL DEFAULT 'manual'
);
CREATE INDEX IF NOT EXISTS idx_area_metrics_month ON area_monthly_metrics(month);