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
61 changes: 53 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@ on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
quality-fast:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,ml]"
- name: Lint
run: ruff check src tests
- name: Typecheck
run: mypy src/freshdata
- name: Test (required fast lane)
run: pytest -m "not online and not large"

test-matrix:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
Expand All @@ -30,15 +58,31 @@ jobs:
pip install -e ".[dev,ml]"
if [ -n "${{ matrix.pandas }}" ]; then pip install "${{ matrix.pandas }}"; fi
if [ -n "${{ matrix.numpy }}" ]; then pip install "${{ matrix.numpy }}"; fi
- name: Lint
run: ruff check src tests
- name: Typecheck
run: mypy src/freshdata
- name: Test
run: pytest
- name: Test (fast marker set)
run: pytest -m "not online and not large"

nightly-online-large:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,ml]"
- name: Nightly online/large drift checks
run: pytest -m "online or large or tier1"

build:
needs: quality-fast
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -54,8 +98,9 @@ jobs:
# Publish a self-hosted shields endpoint badge to the `badges` branch
# (no third-party account needed). Runs only on main.
if: github.ref == 'refs/heads/main'
needs: test
needs: quality-fast
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ concurrency:
jobs:
build-deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
Expand Down
39 changes: 33 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ name: Release
# Publish freshdata-cleaner to PyPI when a version tag is pushed (e.g. v0.5.0),
# or manually via the Actions tab.
#
# Authentication uses an API token stored in the repository secret
# PYPI_API_TOKEN (a PyPI project/account token, username __token__). `skip-existing`
# makes a re-run on an already-published version a no-op success. To switch to PyPI
# Trusted Publishing instead, configure a trusted publisher on the project and replace
# the `password:` line with `permissions: id-token: write`.
# Authentication uses PyPI Trusted Publishing (OIDC). Configure a trusted
# publisher on PyPI for this repository/workflow/environment.

on:
push:
Expand All @@ -18,10 +15,39 @@ on:
permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
preflight:
name: Release quality gate
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,ml,docs]"
- name: Lint
run: ruff check src tests
- name: Typecheck
run: mypy src/freshdata
- name: Test (release fast lane)
run: pytest -m "not online and not large"
- name: Docs strict
run: mkdocs build --strict

build:
name: Build distributions
needs: preflight
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand All @@ -41,11 +67,13 @@ jobs:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/project/freshdata-cleaner/
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
Expand All @@ -54,5 +82,4 @@ jobs:
- name: Publish
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
skip-existing: true
50 changes: 50 additions & 0 deletions QUALITY_OPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Quality Operations Runbook

This runbook defines the weekly quality cadence and the minimum metrics to track
for release readiness.

## Weekly cadence

- **Monday (scope/risk review)**
- Review open PR risk levels (engine changes, fixture changes, workflow changes).
- Confirm required CI lane status and top failure causes from last week.
- **Wednesday (midweek quality check)**
- Review skip counts and flaky failures.
- Verify nightly online/large job output and open drift issues.
- **Friday (merge gate)**
- Merge only PRs with passing required checks.
- Validate release readiness scorecard before tagging.

## Scorecard metrics

Track these weekly (rolling 4-week trend):

- Required CI flake rate (% reruns needed to pass).
- Median required CI duration (minutes).
- `pytest` skip count in required lane.
- Nightly online/large failures (count + top 3 causes).
- Golden snapshot updates merged (count) with diff summaries attached.
- Release gate pass/fail rate.

## Exit criteria for a release candidate

- Required lane flake rate is near zero for the last 2 weeks.
- No unresolved deterministic regression in outlier/repair tests.
- No unexplained golden drift.
- Release workflow preflight passes on tag candidate.

## Operational commands

Required lane locally:

```bash
ruff check src tests
mypy src/freshdata
pytest -m "not online and not large"
```

Nightly lane locally:

```bash
pytest -m "online or large or tier1"
```
18 changes: 10 additions & 8 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ backward-compatible fixes.

## Release checklist

1. **Green main** — `pytest`, `ruff check .`, `mypy src/freshdata`, and
1. **Green main** — `pytest -m "not online and not large"`, `ruff check .`, `mypy src/freshdata`, and
`mkdocs build --strict` all pass.
2. **Bump the version** in `pyproject.toml` and `src/freshdata/__init__.py`.
3. **Update `CHANGELOG.md`** — move `Unreleased` notes under a new
`## [X.Y.Z] - YYYY-MM-DD` heading.
4. **Commit & PR** — merge to `main`.
5. **Build & validate** locally (see below).
6. **Publish to TestPyPI**, smoke-test the install.
7. **Publish to PyPI** (or push the tag and let CI do it).
7. **Publish to PyPI** (push the tag and let CI do it through trusted publishing).
8. **Tag & GitHub release** — `git tag vX.Y.Z` and create the release with
notes from the changelog.
9. **Verify** — `pip install freshdata-cleaner` in a clean environment imports
Expand All @@ -42,10 +42,11 @@ twine check dist/* # validates metadata + long-description renderin

## Publish

### Option A — automated (recommended)
### Option A — automated (required)

Push a version tag; the `Release` workflow builds and publishes via PyPI
**Trusted Publishing** (OIDC, no stored token):
Push a version tag; the `Release` workflow runs a quality gate first
(lint, typecheck, `pytest -m "not online and not large"`, docs strict),
then builds and publishes via PyPI **Trusted Publishing** (OIDC, no stored token):

```bash
git tag v0.5.0
Expand All @@ -56,7 +57,7 @@ One-time setup: add a trusted publisher at
<https://pypi.org/manage/project/freshdata-cleaner/settings/publishing/>
(workflow `release.yml`, environment `pypi`).

### Option B — manual with `twine`
### Option B — manual with `twine` (fallback only)

```bash
# 1. TestPyPI first
Expand All @@ -69,8 +70,9 @@ python -c "import freshdata as fd; print(fd.__version__)"
twine upload dist/*
```

Use a [PyPI API token](https://pypi.org/help/#apitoken) (username `__token__`).
Never commit tokens; prefer `~/.pypirc` or the `TWINE_PASSWORD` env var.
Use a [PyPI API token](https://pypi.org/help/#apitoken) (username `__token__`) only
for emergency/manual fallback releases. Never commit tokens; prefer `~/.pypirc`
or the `TWINE_PASSWORD` env var.

## Naming

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["PLR2004", "SIM117"]
"src/freshdata/engine/model_select.py" = ["PLR0915"]
"src/freshdata/engine/outliers.py" = ["PLR0915"]
"src/freshdata/adapters/polars.py" = ["PLC0415", "PLW0603"]
"scripts/debug_datasets.py" = ["PLR0915"]
# Enterprise layer: lazy optional imports (PLC0415) keep `import freshdata` cheap;
Expand Down
4 changes: 1 addition & 3 deletions scripts/debug_dataset_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@

_REPO = Path(__file__).resolve().parents[1]
_TESTS = _REPO / "tests"
_LOG_PATH = _REPO.parent / ".cursor" / "debug-17298c.log"
_SESSION = "17298c"
_LOG_PATH = _REPO.parent / ".cursor" / "debug_dataset_sweep.log"

if str(_TESTS) not in sys.path:
sys.path.insert(0, str(_TESTS))
Expand All @@ -46,7 +45,6 @@
def _log(hypothesis_id: str, dataset: str, check: str, passed: bool, detail: str = "") -> None:
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
payload = {
"sessionId": _SESSION,
"hypothesisId": hypothesis_id,
"location": "debug_dataset_sweep.py",
"message": check,
Expand Down
26 changes: 24 additions & 2 deletions src/freshdata/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ def plan(
df: pd.DataFrame,
*,
mode: str = "suggest",
max_patches: int | None = None,
max_cells_scanned: int | None = None,
retain_snapshots: bool = True,
config: CleanConfig | None = None,
**options: object,
) -> RepairPlan:
Expand All @@ -147,7 +150,15 @@ def plan(
deterministic representation repairs by disabling statistical engine
actions.
"""
return build_repair_plan(to_pandas(df), mode=mode, config=config, **options)
return build_repair_plan(
to_pandas(df),
mode=mode,
max_patches=max_patches,
max_cells_scanned=max_cells_scanned,
retain_snapshots=retain_snapshots,
config=config,
**options,
)


def repair(
Expand All @@ -156,6 +167,9 @@ def repair(
mode: str = "repair_safe",
approved_patch_ids: set[str] | None = None,
return_plan: bool = False,
max_patches: int | None = None,
max_cells_scanned: int | None = None,
retain_snapshots: bool = True,
config: CleanConfig | None = None,
**options: object,
) -> pd.DataFrame | tuple[pd.DataFrame, RepairPlan]:
Expand All @@ -164,7 +178,15 @@ def repair(
``mode="repair_reviewed"`` applies only ``approved_patch_ids``. Other
modes apply every patch proposed by the plan.
"""
repair_plan = build_repair_plan(to_pandas(df), mode=mode, config=config, **options)
repair_plan = build_repair_plan(
to_pandas(df),
mode=mode,
max_patches=max_patches,
max_cells_scanned=max_cells_scanned,
retain_snapshots=retain_snapshots,
config=config,
**options,
)
approved = set(approved_patch_ids or ()) if mode == "repair_reviewed" else None
repaired = from_pandas(repair_plan.apply(approved), df)
if return_plan:
Expand Down
Loading
Loading