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"