From bfa0129648be2d868a197bcf86f2b8e887b130c7 Mon Sep 17 00:00:00 2001 From: jaeyeopme Date: Sun, 7 Jun 2026 01:06:13 +0900 Subject: [PATCH] feat(export): connect job export service to api and cli Add the reusable job export orchestration path and wire it through FastAPI and Typer. Persist validated export run records before returning the final summary, so count validation lives at the workflow boundary. Tested: uv run pytest tests/test_models.py tests/test_repository.py tests/test_export_service.py tests/test_app.py tests/test_cli.py -v Tested: uv run ruff check src/clean_data_export_api/models.py src/clean_data_export_api/repository.py src/clean_data_export_api/export_service.py src/clean_data_export_api/app.py src/clean_data_export_api/cli.py tests/test_models.py tests/test_repository.py tests/test_export_service.py tests/test_app.py tests/test_cli.py Tested: uv run basedpyright Tested: uv run pytest -v Tested: uv run pytest tests/test_export_service.py tests/test_app.py tests/test_cli.py --cov=clean_data_export_api --cov-report=term-missing Tested: uv run pytest --cov=clean_data_export_api --cov-report=term-missing --cov-fail-under=90 Tested: uv run python -m compileall src tests Tested: uv run clean-data-export-api export jobs --from-date 2026-06-01 --to-date 2026-06-05 --output-dir /outputs --database-path /history.sqlite3 --page-size 4 Tested: git diff --check --- pyproject.toml | 2 +- src/clean_data_export_api/app.py | 23 +++++ src/clean_data_export_api/cli.py | 64 +++++++++++++- src/clean_data_export_api/export_service.py | 83 +++++++++++++++++ src/clean_data_export_api/models.py | 31 ++++++- src/clean_data_export_api/repository.py | 56 +++++------- tests/test_app.py | 67 ++++++++++++++ tests/test_cli.py | 41 ++++++++- tests/test_export_service.py | 98 +++++++++++++++++++++ tests/test_models.py | 51 ++++++++++- tests/test_repository.py | 57 ++++++------ uv.lock | 44 ++++----- 12 files changed, 520 insertions(+), 97 deletions(-) create mode 100644 src/clean_data_export_api/app.py create mode 100644 src/clean_data_export_api/export_service.py create mode 100644 tests/test_app.py create mode 100644 tests/test_export_service.py diff --git a/pyproject.toml b/pyproject.toml index 5942e7e..a15d74b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ clean-data-export-api = "clean_data_export_api.cli:app" [dependency-groups] dev = [ "basedpyright>=1.39.6", - "httpx>=0.27.0", + "httpx2>=2.3.0", "pytest>=8.0.0", "pytest-cov>=6.0.0", "ruff>=0.8.0", diff --git a/src/clean_data_export_api/app.py b/src/clean_data_export_api/app.py new file mode 100644 index 0000000..dd2213a --- /dev/null +++ b/src/clean_data_export_api/app.py @@ -0,0 +1,23 @@ +"""FastAPI application for the local clean job export workflow.""" + +from fastapi import FastAPI + +from clean_data_export_api.config import RuntimeSettings, get_default_settings +from clean_data_export_api.export_service import run_job_export +from clean_data_export_api.models import ExportJobsRequest, ExportRequest, ExportSummary + + +def create_app(settings: RuntimeSettings | None = None) -> FastAPI: + """Create a FastAPI app with local runtime settings.""" + runtime_settings = settings or get_default_settings() + app = FastAPI(title="Clean Data Export API") + + @app.post("/exports/jobs", response_model=ExportSummary) + def export_jobs(request: ExportJobsRequest) -> ExportSummary: + export_request = ExportRequest(from_date=request.from_date, to_date=request.to_date) + return run_job_export(export_request, settings=runtime_settings) + + return app + + +app = create_app() diff --git a/src/clean_data_export_api/cli.py b/src/clean_data_export_api/cli.py index d902b98..efdf629 100644 --- a/src/clean_data_export_api/cli.py +++ b/src/clean_data_export_api/cli.py @@ -1,13 +1,73 @@ -"""Command shell for the Clean Data Export API project.""" +"""Command line interface for the Clean Data Export API project.""" + +import json +from datetime import date +from pathlib import Path +from typing import Annotated import typer +from clean_data_export_api.config import RuntimeSettings +from clean_data_export_api.export_service import run_job_export +from clean_data_export_api.models import ExportRequest + app = typer.Typer( - help="Command shell for the planned clean job export workflow.", + help="Clean job export workflow commands.", no_args_is_help=True, ) +export_app = typer.Typer(help="Run export workflows.", no_args_is_help=True) +app.add_typer(export_app, name="export") @app.callback() def main() -> None: """Show project command help.""" + + +@export_app.command("jobs") +def export_jobs( + from_date: Annotated[str, typer.Option("--from-date")], + to_date: Annotated[str, typer.Option("--to-date")], + sample_data_dir: Annotated[Path, typer.Option("--sample-data-dir")] = Path("sample_data"), + output_dir: Annotated[Path, typer.Option("--output-dir")] = Path("outputs"), + database_path: Annotated[Path, typer.Option("--database-path")] = Path( + "outputs/run_history.sqlite3" + ), + page_size: Annotated[int, typer.Option("--page-size", min=1)] = 50, +) -> None: + """Export demo job records to clean handoff files.""" + parsed_from_date = _parse_date_option(from_date, "from_date") + parsed_to_date = _parse_date_option(to_date, "to_date") + settings = RuntimeSettings( + sample_data_dir=sample_data_dir, + output_dir=output_dir, + database_path=database_path, + page_size=page_size, + ) + summary = run_job_export( + ExportRequest( + from_date=parsed_from_date, + to_date=parsed_to_date, + output_dir=output_dir, + ), + settings=settings, + ) + typer.echo(json.dumps(summary.model_dump(mode="json"), indent=2)) + + +@app.command() +def serve( + host: Annotated[str, typer.Option("--host")] = "127.0.0.1", + port: Annotated[int, typer.Option("--port", min=1, max=65535)] = 8000, +) -> None: + """Serve the export API locally.""" + import uvicorn + + uvicorn.run("clean_data_export_api.app:app", host=host, port=port, reload=False) + + +def _parse_date_option(value: str, field_name: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as error: + raise typer.BadParameter(f"{field_name} must be a valid ISO date") from error diff --git a/src/clean_data_export_api/export_service.py b/src/clean_data_export_api/export_service.py new file mode 100644 index 0000000..140a9c5 --- /dev/null +++ b/src/clean_data_export_api/export_service.py @@ -0,0 +1,83 @@ +"""Orchestrate the clean job export workflow.""" + +from clean_data_export_api.config import RuntimeSettings, get_default_settings +from clean_data_export_api.mapping import map_source_jobs +from clean_data_export_api.models import ExportRequest, ExportRunRecord, ExportSummary, SourceJob +from clean_data_export_api.reports import output_paths_for, write_reports +from clean_data_export_api.repository import RunRepository +from clean_data_export_api.source_api import FixtureSourceAPI, SourceJobPage +from clean_data_export_api.validation import partition_valid_jobs, remove_duplicate_jobs + + +def run_job_export( + request: ExportRequest, + *, + settings: RuntimeSettings | None = None, +) -> ExportSummary: + """Run the full local job export workflow and return its summary.""" + runtime_settings = settings or get_default_settings() + output_dir = request.output_dir or runtime_settings.output_dir + + source_api = FixtureSourceAPI(runtime_settings) + source_jobs = _fetch_all_jobs(source_api, request, runtime_settings.page_size) + source_job_ids = tuple(job.job_id for job in source_jobs if job.job_id) + job_updates = source_api.list_job_updates(source_job_ids) + + candidates = map_source_jobs(source_jobs, job_updates) + valid_jobs, validation_rejects = partition_valid_jobs(candidates) + clean_jobs, duplicate_rejects = remove_duplicate_jobs(valid_jobs) + rejected_rows = (*validation_rejects, *duplicate_rejects) + + output_paths = output_paths_for(output_dir) + run_record = ExportRunRecord( + from_date=request.from_date, + to_date=request.to_date, + source_count=len(source_jobs), + clean_count=len(clean_jobs), + duplicate_count=len(duplicate_rejects), + rejected_count=len(rejected_rows), + output_paths=output_paths, + ) + + run_repository = RunRepository(runtime_settings.database_path) + run_id = run_repository.insert_run(run_record) + summary = ExportSummary( + run_id=run_id, + from_date=run_record.from_date, + to_date=run_record.to_date, + source_count=run_record.source_count, + clean_count=run_record.clean_count, + duplicate_count=run_record.duplicate_count, + rejected_count=run_record.rejected_count, + output_paths=run_record.output_paths, + ) + try: + write_reports(clean_jobs=clean_jobs, rejected_rows=rejected_rows, summary=summary) + except Exception as error: + try: + run_repository.delete_run(run_id) + except Exception as cleanup_error: + error.add_note(f"Could not remove failed export run {run_id}: {cleanup_error}") + raise + return summary + + +def _fetch_all_jobs( + source_api: FixtureSourceAPI, + request: ExportRequest, + page_size: int, +) -> tuple[SourceJob, ...]: + records: list[SourceJob] = [] + cursor = 0 + + while True: + page: SourceJobPage = source_api.list_jobs( + from_date=request.from_date, + to_date=request.to_date, + cursor=cursor, + page_size=page_size, + ) + records.extend(page.items) + if page.next_cursor is None: + return tuple(records) + cursor = page.next_cursor diff --git a/src/clean_data_export_api/models.py b/src/clean_data_export_api/models.py index 5be3b12..460a00a 100644 --- a/src/clean_data_export_api/models.py +++ b/src/clean_data_export_api/models.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Literal, Self -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator RejectedReasonCode = Literal[ "missing_job_id", @@ -25,12 +25,13 @@ ) -class ExportRequest(BaseModel): - """Request contract shared by future API and CLI entry points.""" +class ExportDateRange(BaseModel): + """Shared inclusive date range contract for export requests.""" + + model_config = ConfigDict(extra="forbid") from_date: date to_date: date - output_dir: Path = Path("outputs") @model_validator(mode="after") def validate_date_range(self) -> Self: @@ -41,6 +42,16 @@ def validate_date_range(self) -> Self: return self +class ExportJobsRequest(ExportDateRange): + """Public API request body for the jobs export endpoint.""" + + +class ExportRequest(ExportDateRange): + """Internal export request passed to orchestration.""" + + output_dir: Path | None = None + + class OutputPaths(BaseModel): """Paths written by a completed export run.""" @@ -50,6 +61,18 @@ class OutputPaths(BaseModel): run_summary: Path +class ExportRunRecord(BaseModel): + """Validated export run data persisted before the final summary is returned.""" + + from_date: date + to_date: date + source_count: int = Field(ge=0) + clean_count: int = Field(ge=0) + duplicate_count: int = Field(ge=0) + rejected_count: int = Field(ge=0) + output_paths: OutputPaths + + class ExportSummary(BaseModel): """Summary returned by the export service and entry points.""" diff --git a/src/clean_data_export_api/repository.py b/src/clean_data_export_api/repository.py index 4b3c062..02b9554 100644 --- a/src/clean_data_export_api/repository.py +++ b/src/clean_data_export_api/repository.py @@ -2,10 +2,10 @@ import sqlite3 from contextlib import closing -from datetime import UTC, date, datetime +from datetime import UTC, datetime from pathlib import Path -from clean_data_export_api.models import OutputPaths +from clean_data_export_api.models import ExportRunRecord EXPORT_RUN_COLUMNS: tuple[str, ...] = ( "id", @@ -47,6 +47,7 @@ f"insert into export_runs ({', '.join(_EXPORT_RUN_VALUE_COLUMNS)}) " f"values ({', '.join('?' for _ in _EXPORT_RUN_VALUE_COLUMNS)})" ) +_DELETE_EXPORT_RUN_SQL = "delete from export_runs where id = ?" class RunRepository: @@ -57,14 +58,8 @@ def __init__(self, database_path: Path) -> None: def insert_run( self, + record: ExportRunRecord, *, - from_date: date, - to_date: date, - source_count: int, - clean_count: int, - duplicate_count: int, - rejected_count: int, - output_paths: OutputPaths, started_at: datetime | None = None, ) -> int: """Insert one export run and return its run id.""" @@ -74,13 +69,7 @@ def insert_run( cursor = connection.execute( _INSERT_EXPORT_RUN_SQL, _export_run_values( - from_date=from_date, - to_date=to_date, - source_count=source_count, - clean_count=clean_count, - duplicate_count=duplicate_count, - rejected_count=rejected_count, - output_paths=output_paths, + record=record, started_at=started_at or datetime.now(UTC), ), ) @@ -91,6 +80,13 @@ def insert_run( connection.commit() return run_id + def delete_run(self, run_id: int) -> None: + """Delete one export run by id.""" + with closing(sqlite3.connect(self._database_path)) as connection: + _ensure_schema(connection) + connection.execute(_DELETE_EXPORT_RUN_SQL, (run_id,)) + connection.commit() + def _ensure_schema(connection: sqlite3.Connection) -> None: connection.execute(_CREATE_EXPORT_RUNS_SQL) @@ -98,27 +94,21 @@ def _ensure_schema(connection: sqlite3.Connection) -> None: def _export_run_values( *, - from_date: date, - to_date: date, - source_count: int, - clean_count: int, - duplicate_count: int, - rejected_count: int, - output_paths: OutputPaths, + record: ExportRunRecord, started_at: datetime, ) -> tuple[str, str, str, int, int, int, int, str, str, str, str]: return ( _format_timestamp(started_at), - from_date.isoformat(), - to_date.isoformat(), - source_count, - clean_count, - duplicate_count, - rejected_count, - str(output_paths.clean_csv), - str(output_paths.clean_json), - str(output_paths.rejected_csv), - str(output_paths.run_summary), + record.from_date.isoformat(), + record.to_date.isoformat(), + record.source_count, + record.clean_count, + record.duplicate_count, + record.rejected_count, + str(record.output_paths.clean_csv), + str(record.output_paths.clean_json), + str(record.output_paths.rejected_csv), + str(record.output_paths.run_summary), ) diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..43f83c7 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from clean_data_export_api.app import create_app +from clean_data_export_api.config import RuntimeSettings + + +def test_export_jobs_endpoint_returns_summary(tmp_path) -> None: + output_dir = tmp_path / "outputs" + app = create_app( + RuntimeSettings( + sample_data_dir=Path("sample_data"), + output_dir=output_dir, + database_path=tmp_path / "history.sqlite3", + page_size=4, + ) + ) + client = TestClient(app) + + response = client.post( + "/exports/jobs", + json={ + "from_date": "2026-06-01", + "to_date": "2026-06-05", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["run_id"] == 1 + assert payload["source_count"] == 9 + assert payload["clean_count"] == 3 + assert payload["duplicate_count"] == 1 + assert payload["rejected_count"] == 6 + assert payload["output_paths"]["clean_csv"] == str(output_dir / "clean_jobs.csv") + assert (output_dir / "clean_jobs.csv").exists() + + +@pytest.mark.parametrize("output_dir_kind", ("absolute", "parent")) +def test_export_jobs_endpoint_rejects_caller_controlled_output_dir( + tmp_path, + output_dir_kind: str, +) -> None: + app = create_app( + RuntimeSettings( + sample_data_dir=Path("sample_data"), + output_dir=tmp_path / "outputs", + database_path=tmp_path / "history.sqlite3", + ) + ) + client = TestClient(app) + output_dir = ( + str(tmp_path / "unsafe-export") if output_dir_kind == "absolute" else "../unsafe-export" + ) + + response = client.post( + "/exports/jobs", + json={ + "from_date": "2026-06-01", + "to_date": "2026-06-05", + "output_dir": output_dir, + }, + ) + + assert response.status_code == 422 diff --git a/tests/test_cli.py b/tests/test_cli.py index 8fc1039..385f221 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,14 +1,47 @@ +import json + from typer.testing import CliRunner from clean_data_export_api.cli import app -def test_cli_help_shows_shell_without_export_commands() -> None: +def test_cli_help_shows_export_and_serve_commands() -> None: runner = CliRunner() result = runner.invoke(app, ["--help"]) assert result.exit_code == 0 - assert "planned clean job export workflow" in result.output - assert "export jobs" not in result.output - assert "serve" not in result.output + assert "export" in result.output + assert "serve" in result.output + + +def test_export_jobs_cli_returns_summary(tmp_path) -> None: + runner = CliRunner() + + result = runner.invoke( + app, + [ + "export", + "jobs", + "--from-date", + "2026-06-01", + "--to-date", + "2026-06-05", + "--sample-data-dir", + "sample_data", + "--output-dir", + str(tmp_path / "outputs"), + "--database-path", + str(tmp_path / "history.sqlite3"), + "--page-size", + "4", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["source_count"] == 9 + assert payload["clean_count"] == 3 + assert payload["duplicate_count"] == 1 + assert payload["rejected_count"] == 6 + assert payload["output_paths"]["clean_csv"].endswith("clean_jobs.csv") diff --git a/tests/test_export_service.py b/tests/test_export_service.py new file mode 100644 index 0000000..fb36406 --- /dev/null +++ b/tests/test_export_service.py @@ -0,0 +1,98 @@ +import csv +import shutil +import sqlite3 +from contextlib import closing +from datetime import date +from pathlib import Path + +import pytest + +from clean_data_export_api.config import RuntimeSettings +from clean_data_export_api.export_service import run_job_export +from clean_data_export_api.models import ExportRequest + + +def test_run_job_export_creates_expected_demo_outputs(tmp_path) -> None: + output_dir = tmp_path / "outputs" + settings = RuntimeSettings( + sample_data_dir=tmp_path / "sample_data", + output_dir=output_dir, + database_path=tmp_path / "run_history.sqlite3", + page_size=4, + ) + settings.sample_data_dir.mkdir() + _copy_fixture_files(settings.sample_data_dir) + + summary = run_job_export( + ExportRequest( + from_date=date(2026, 6, 1), + to_date=date(2026, 6, 5), + output_dir=output_dir, + ), + settings=settings, + ) + + assert summary.run_id == 1 + assert summary.source_count == 9 + assert summary.clean_count == 3 + assert summary.duplicate_count == 1 + assert summary.rejected_count == 6 + assert summary.output_paths.clean_csv == output_dir / "clean_jobs.csv" + assert summary.output_paths.clean_json.exists() + assert summary.output_paths.rejected_csv.exists() + assert summary.output_paths.run_summary.exists() + + with summary.output_paths.clean_csv.open(newline="", encoding="utf-8") as file: + clean_rows = list(csv.DictReader(file)) + assert [row["job_id"] for row in clean_rows] == ["JOB-1001", "JOB-1002", "JOB-1003"] + + with summary.output_paths.rejected_csv.open(newline="", encoding="utf-8") as file: + rejected_rows = list(csv.DictReader(file)) + assert {row["reason_code"] for row in rejected_rows} == { + "missing_job_id", + "missing_customer_name", + "missing_work_type", + "invalid_status", + "invalid_date", + "duplicate_record", + } + + with closing(sqlite3.connect(settings.database_path)) as connection: + row = connection.execute( + """ + select source_count, clean_count, duplicate_count, rejected_count + from export_runs + """ + ).fetchone() + assert row == (9, 3, 1, 6) + + +def test_run_job_export_removes_history_when_report_write_fails(tmp_path) -> None: + output_dir = tmp_path / "blocked-output" + output_dir.write_text("not a directory", encoding="utf-8") + settings = RuntimeSettings( + sample_data_dir=tmp_path / "sample_data", + output_dir=output_dir, + database_path=tmp_path / "run_history.sqlite3", + page_size=4, + ) + settings.sample_data_dir.mkdir() + _copy_fixture_files(settings.sample_data_dir) + + with pytest.raises(FileExistsError): + run_job_export( + ExportRequest( + from_date=date(2026, 6, 1), + to_date=date(2026, 6, 5), + ), + settings=settings, + ) + + with closing(sqlite3.connect(settings.database_path)) as connection: + row_count = connection.execute("select count(*) from export_runs").fetchone()[0] + assert row_count == 0 + + +def _copy_fixture_files(target_dir: Path) -> None: + for filename in ("source_jobs.json", "source_job_updates.json"): + shutil.copyfile(Path("sample_data") / filename, target_dir / filename) diff --git a/tests/test_models.py b/tests/test_models.py index 9a0b993..84845a7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,9 @@ from clean_data_export_api.models import ( REQUIRED_REJECTED_REASON_CODES, CleanJob, + ExportJobsRequest, ExportRequest, + ExportRunRecord, ExportSummary, OutputPaths, RejectedRow, @@ -26,13 +28,11 @@ def test_export_request_accepts_inclusive_date_range() -> None: - request = ExportRequest.model_validate( - {"from_date": "2026-06-01", "to_date": "2026-06-05"} - ) + request = ExportRequest.model_validate({"from_date": "2026-06-01", "to_date": "2026-06-05"}) assert request.from_date == date(2026, 6, 1) assert request.to_date == date(2026, 6, 5) - assert request.output_dir == Path("outputs") + assert request.output_dir is None def test_export_request_rejects_reversed_date_range() -> None: @@ -40,6 +40,17 @@ def test_export_request_rejects_reversed_date_range() -> None: ExportRequest.model_validate({"from_date": "2026-06-05", "to_date": "2026-06-01"}) +def test_export_jobs_request_rejects_extra_fields() -> None: + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ExportJobsRequest.model_validate( + { + "from_date": "2026-06-01", + "to_date": "2026-06-05", + "output_dir": "outputs", + } + ) + + def test_rejected_reason_codes_match_public_docs() -> None: docs_text = Path("docs/PRD.md").read_text() + Path("docs/DELIVERY.md").read_text() @@ -137,3 +148,35 @@ def test_export_summary_rejects_negative_counts() -> None: rejected_count=0, output_paths=output_paths, ) + + +def test_export_run_record_rejects_negative_counts() -> None: + output_paths = OutputPaths( + clean_csv=Path("outputs/clean_jobs.csv"), + clean_json=Path("outputs/clean_jobs.json"), + rejected_csv=Path("outputs/rejected_jobs.csv"), + run_summary=Path("outputs/run_summary.md"), + ) + + record = ExportRunRecord( + from_date=date(2026, 6, 1), + to_date=date(2026, 6, 5), + source_count=9, + clean_count=3, + duplicate_count=1, + rejected_count=6, + output_paths=output_paths, + ) + + assert record.clean_count == 3 + + with pytest.raises(ValidationError): + ExportRunRecord( + from_date=date(2026, 6, 1), + to_date=date(2026, 6, 5), + source_count=9, + clean_count=-1, + duplicate_count=1, + rejected_count=6, + output_paths=output_paths, + ) diff --git a/tests/test_repository.py b/tests/test_repository.py index cd783a7..049dd9e 100644 --- a/tests/test_repository.py +++ b/tests/test_repository.py @@ -3,7 +3,7 @@ from datetime import UTC, date, datetime from pathlib import Path -from clean_data_export_api.models import OutputPaths +from clean_data_export_api.models import ExportRunRecord, OutputPaths from clean_data_export_api.repository import EXPORT_RUN_COLUMNS, RunRepository @@ -14,21 +14,20 @@ def test_insert_run_persists_export_history_with_documented_columns(tmp_path) -> started_at = datetime(2026, 6, 6, 12, 0, tzinfo=UTC) run_id = repository.insert_run( - from_date=date(2026, 6, 1), - to_date=date(2026, 6, 5), - source_count=9, - clean_count=3, - duplicate_count=1, - rejected_count=6, - output_paths=output_paths, + _run_record( + output_paths=output_paths, + source_count=9, + clean_count=3, + duplicate_count=1, + rejected_count=6, + ), started_at=started_at, ) assert run_id == 1 with closing(sqlite3.connect(db_path)) as connection: columns = tuple( - column[1] - for column in connection.execute("pragma table_info(export_runs)").fetchall() + column[1] for column in connection.execute("pragma table_info(export_runs)").fetchall() ) select_export_runs_sql = "select " + ", ".join(EXPORT_RUN_COLUMNS) + " from export_runs" row = connection.execute(select_export_runs_sql).fetchone() @@ -53,29 +52,33 @@ def test_insert_run_persists_export_history_with_documented_columns(tmp_path) -> def test_insert_run_appends_history_rows(tmp_path) -> None: repository = RunRepository(tmp_path / "history.sqlite3") output_paths = _output_paths(tmp_path / "outputs") + record = _run_record(output_paths=output_paths) - first_run_id = repository.insert_run( - from_date=date(2026, 6, 1), - to_date=date(2026, 6, 5), - source_count=9, - clean_count=3, - duplicate_count=1, - rejected_count=6, - output_paths=output_paths, - ) - second_run_id = repository.insert_run( + first_run_id = repository.insert_run(record) + second_run_id = repository.insert_run(record) + + assert first_run_id == 1 + assert second_run_id == 2 + + +def _run_record( + *, + output_paths: OutputPaths, + source_count: int = 9, + clean_count: int = 3, + duplicate_count: int = 1, + rejected_count: int = 6, +) -> ExportRunRecord: + return ExportRunRecord( from_date=date(2026, 6, 1), to_date=date(2026, 6, 5), - source_count=9, - clean_count=3, - duplicate_count=1, - rejected_count=6, + source_count=source_count, + clean_count=clean_count, + duplicate_count=duplicate_count, + rejected_count=rejected_count, output_paths=output_paths, ) - assert first_run_id == 1 - assert second_run_id == 2 - def _output_paths(output_dir: Path) -> OutputPaths: return OutputPaths( diff --git a/uv.lock b/uv.lock index 049d6f8..916f3c2 100644 --- a/uv.lock +++ b/uv.lock @@ -45,15 +45,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/07/6d1b3192715d42e8c9887876684a941eff28ec5d79c23a0f3758377e2182/basedpyright-1.39.6-py3-none-any.whl", hash = "sha256:5e0b9befbae6b26d0fbcc6645ac26923725e749d1224539e24f05ab07f9365ad", size = 13182122, upload-time = "2026-05-24T07:44:47.086Z" }, ] -[[package]] -name = "certifi" -version = "2026.5.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, -] - [[package]] name = "clean-data-export-api" version = "0.1.0" @@ -68,7 +59,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "basedpyright" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, @@ -85,7 +76,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.39.6" }, - { name = "httpx", specifier = ">=0.27.0" }, + { name = "httpx2", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "ruff", specifier = ">=0.8.0" }, @@ -242,31 +233,31 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, + { url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" }, ] [[package]] @@ -612,6 +603,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.7"