Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

on:
pull_request:
push:
branches:
- main
- project-foundation

jobs:
verify:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.13"

steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Set up uv
uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true

- name: Install dependencies
run: uv sync --locked --python "${{ matrix.python-version }}"

- name: Run targeted tests
run: uv run pytest tests/test_config.py tests/test_models.py tests/test_cli.py -v

- name: Run ruff
run: uv run ruff check src tests

- name: Run basedpyright
run: uv run basedpyright

- name: Run coverage gate
run: >
uv run pytest tests/test_config.py tests/test_models.py tests/test_cli.py
--cov=clean_data_export_api
--cov-report=term-missing
--cov-fail-under=90
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
cov_annotate/
build/
dist/
68 changes: 68 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"

[project]
name = "clean-data-export-api"
version = "0.1.0"
description = "Local clean data export workflow for fictional FieldOps Desk job records."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"pydantic>=2.10.0",
"typer>=0.15.0",
"uvicorn>=0.32.0",
]

[project.scripts]
clean-data-export-api = "clean_data_export_api.cli:app"

[dependency-groups]
dev = [
"basedpyright>=1.39.6",
"httpx>=0.27.0",
"pytest>=8.0.0",
"pytest-cov>=6.0.0",
"ruff>=0.8.0",
]

[tool.hatch.build.targets.wheel]
packages = ["src/clean_data_export_api"]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = [
"--strict-config",
"--strict-markers",
]

[tool.coverage.run]
branch = true
source = ["clean_data_export_api"]

[tool.coverage.report]
show_missing = true
skip_covered = true

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = [
"E",
"F",
"I",
"UP",
"B",
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"

[tool.basedpyright]
include = ["src", "tests"]
pythonVersion = "3.11"
typeCheckingMode = "standard"
5 changes: 5 additions & 0 deletions src/clean_data_export_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Clean Data Export API package."""

__all__ = ["__version__"]

__version__ = "0.1.0"
13 changes: 13 additions & 0 deletions src/clean_data_export_api/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Command shell for the Clean Data Export API project."""

import typer

app = typer.Typer(
help="Command shell for the planned clean job export workflow.",
no_args_is_help=True,
)


@app.callback()
def main() -> None:
"""Show project command help."""
19 changes: 19 additions & 0 deletions src/clean_data_export_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Runtime defaults for the local export workflow."""

from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class RuntimeSettings:
"""Local paths and fixture API defaults used by the workflow."""

sample_data_dir: Path = Path("sample_data")
output_dir: Path = Path("outputs")
database_path: Path = Path("outputs/run_history.sqlite3")
page_size: int = 50


def get_default_settings() -> RuntimeSettings:
"""Return default local runtime settings."""
return RuntimeSettings()
107 changes: 107 additions & 0 deletions src/clean_data_export_api/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Typed contracts for the planned clean job export workflow."""

from datetime import date, datetime
from pathlib import Path
from typing import Any, Literal, Self

from pydantic import BaseModel, Field, model_validator

RejectedReasonCode = Literal[
"missing_job_id",
"missing_customer_name",
"missing_work_type",
"invalid_status",
"invalid_date",
"duplicate_record",
]

REQUIRED_REJECTED_REASON_CODES: tuple[str, ...] = (
"missing_job_id",
"missing_customer_name",
"missing_work_type",
"invalid_status",
"invalid_date",
"duplicate_record",
)


class ExportRequest(BaseModel):
"""Request contract shared by future API and CLI entry points."""

from_date: date
to_date: date
output_dir: Path = Path("outputs")

@model_validator(mode="after")
def validate_date_range(self) -> Self:
"""Reject reversed date ranges before orchestration starts."""
if self.from_date > self.to_date:
msg = "from_date must be before or equal to to_date"
raise ValueError(msg)
return self


class OutputPaths(BaseModel):
"""Paths written by a completed export run."""

clean_csv: Path
clean_json: Path
rejected_csv: Path
run_summary: Path


class ExportSummary(BaseModel):
"""Summary returned by the export service and entry points."""

run_id: int = Field(ge=1)
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 SourceJob(BaseModel):
"""Raw job record shape loaded from the fictional source fixture."""

source_row_id: str
job_id: str | None = None
customer_name: str | None = None
status: str | None = None
work_type: str | None = None
scheduled_date: str | None = None
updated_at: str | None = None


class SourceJobUpdate(BaseModel):
"""Raw job update record shape loaded from the fictional source fixture."""

update_id: str
job_id: str
status: str | None = None
note: str | None = None
updated_at: str


class CleanJob(BaseModel):
"""Accepted job output shape written to clean CSV and JSON files."""

job_id: str
customer_name: str
job_status: str
work_type: str
scheduled_date: date
last_updated_at: datetime
source_updated_at: datetime


class RejectedRow(BaseModel):
"""Rejected source record with a stable reason code and raw context."""

source_row_id: str | None = None
source_job_id: str | None = None
reason_code: RejectedReasonCode
reason_detail: str
raw_record: dict[str, Any]
1 change: 1 addition & 0 deletions src/clean_data_export_api/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

14 changes: 14 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typer.testing import CliRunner

from clean_data_export_api.cli import app


def test_cli_help_shows_shell_without_export_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
13 changes: 13 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pathlib import Path

from clean_data_export_api.config import RuntimeSettings, get_default_settings


def test_default_settings_use_local_project_paths() -> None:
settings = get_default_settings()

assert settings == RuntimeSettings()
assert settings.sample_data_dir == Path("sample_data")
assert settings.output_dir == Path("outputs")
assert settings.database_path == Path("outputs/run_history.sqlite3")
assert settings.page_size == 50
Loading