diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d29226e..b4be8dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,12 @@ jobs: id-token: write steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 + + - name: Engagement guard + run: python3 scripts/engagement_guard.py + + - name: Set up Python + uses: actions/setup-python@v6 with: python-version: "3.12" - run: python -m pip install build diff --git a/.gitignore b/.gitignore index e6b130a..74e4b08 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,11 @@ secrets.* # UI-review tool output (regenerated each run) screenshots/ + +# --- lailara engagement scaffold --- +# Client engagement data is runtime-only: never commit it, never deploy it. +client-data/ +client-output/ +/engagement.yml +/engagement.yaml +# (engagement.demo.yml and engagement.example.yml stay committable) diff --git a/CHANGELOG.md b/CHANGELOG.md index 947fb3b..c4aabc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to datascope are documented here. +## [Unreleased] + +### Fixed +- HTML report generator timestamp now honors `SOURCE_DATE_EPOCH` (the reproducible-builds standard), so regenerated sample reports are byte-identical run-to-run. A bare `datetime.now()` in the report footer otherwise changed every run and defeated any byte-lock on the output. + +### Changed +- Regenerated `samples/output/` from current source with `SOURCE_DATE_EPOCH` pinned, so the shipped showcase artifacts reflect the 2.4.0 tool and are reproducible. See `scripts/regenerate_samples.sh`. (The annotated `.xlsx` content is reproducible but its openpyxl envelope carries wall-clock member mtimes, so it is not raw-byte identical — the `.html`/`.pdf` are.) + +## [2.4.0] — 2026-08-05 + +### Fixed +- **Mixed date formats are now detected in CSV files.** The CSV loader coerced date-like strings to `datetime` on load, erasing the raw format before the mixed-date analyzer could see it — so a column mixing `2026-01-01` and `01/02/2026` was silently reconciled and never flagged, the exact silent coercion datascope exists to surface. Date-like CSV cells are now kept as strings (a CSV has no type metadata; a date is text), so `analyze_mixed_dates` sees the raw formats and reports the inconsistency. Excel date cells, which arrive already typed from openpyxl, are unaffected. + +### Docs +- README missing-value row now names both thresholds it depends on — flagged at ≥10% blank (`_DEFAULT_THRESHOLD_PCT`) and Warning at ≥50% / Info below (`findings/severity.py`). Correction: an earlier draft of this entry read "threshold now reads 10%", which put the flag floor into a row labeled *Warning* — the two are different decisions and the row now states both. +- Removed the CSV mixed-date caveat from the README. The loader fix above now surfaces mixed dates in CSVs, so the caveat added in 2.3.4 ("supply as `.xlsx`") no longer holds. + ## [2.3.4] — 2026-07-31 ### Fixed diff --git a/README.md b/README.md index 92953df..d22a0f4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ datascope finds these problems, explains what's wrong in plain English, and tell |---|---|---| | **Mixed types** | 485 numbers + 15 strings in a "numeric" column | Critical | | **Sentinel values** | "N/A", "TBD", "pending" hiding in numeric data | Critical | -| **Missing values** | 50%+ of a column is blank — aggregations silently exclude those rows (below 50% is flagged Info) | Warning | +| **Missing values** | Flagged at ≥10% blank; Warning at ≥50%, Info below — aggregations silently exclude those rows | Warning / Info | | **Leading-zero inconsistency** | "00123" alongside "456" — keys that won't match | Warning | | **Mixed date formats** | "01/15/2026" and "2026-01-15" in the same column | Warning | | **Suspected duplicate IDs** | 98% unique in an ID column — the other 2% will fan out joins | Warning | @@ -27,8 +27,6 @@ datascope finds these problems, explains what's wrong in plain English, and tell Each finding is expressed as **assumption vs. reality**: what the data *appears* to be vs. what it *actually contains*. Every finding includes a downstream impact explanation, a fix recommendation, and a prevention rule. -> **Note on CSV input:** date strings written in formats the CSV loader recognizes (e.g. `2026-01-15` and `01/15/2026`) are parsed to real dates at load time, so a CSV column mixing those two formats is normalized before analysis and does **not** raise a *Mixed date formats* finding. To surface mixed date formats, supply the column as text — for example in an `.xlsx` file with text-formatted cells, where the values stay strings and the check fires as expected. - --- ## Installation diff --git a/datascope/__init__.py b/datascope/__init__.py index 15ecef3..78f7ade 100644 --- a/datascope/__init__.py +++ b/datascope/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "2.3.4" +__version__ = "2.4.0" diff --git a/datascope/loaders/csv_loader.py b/datascope/loaders/csv_loader.py index a021394..d6db8d0 100644 --- a/datascope/loaders/csv_loader.py +++ b/datascope/loaders/csv_loader.py @@ -12,12 +12,10 @@ from __future__ import annotations import csv -from datetime import datetime from pathlib import Path import pandas as pd -from datascope.analyzers.format_check import DATE_LIKE_RE from datascope.loaders.base import dedupe_headers from datascope.models import LoaderResult @@ -30,17 +28,6 @@ # it must stay a string rather than becoming ``inf``/``NaN``. _NON_FINITE_STRS = frozenset({"inf", "infinity", "nan"}) -# Date/time formats tried in order (most specific first). -_DATETIME_FMTS = ( - "%Y-%m-%dT%H:%M:%S", # ISO 8601 - "%Y-%m-%d %H:%M:%S", # space-separated - "%Y-%m-%d", # date only - "%m/%d/%Y %H:%M:%S", - "%m/%d/%Y", - "%d/%m/%Y", - "%Y/%m/%d", -) - def _infer_cell(raw: str) -> object: """Infer a single cell's Python value from its raw CSV string. @@ -50,8 +37,14 @@ def _infer_cell(raw: str) -> object: 2. Integer 3. Float 4. Boolean (true/false/yes/no, case-insensitive) - 5. Datetime (common formats) - 6. String fallback + 5. String fallback + + Date-like cells are DELIBERATELY left as strings. A CSV has no type + metadata, so a date is text; coercing it to ``datetime`` here would erase the + very format evidence the mixed-date analyzer needs, silently hiding a + mixed-format column ("2026-01-01" vs "01/02/2026") — exactly the silent + coercion datascope exists to surface. (Excel dates arrive already typed from + openpyxl, so the Excel loader keeps its datetime cells.) """ stripped = raw.strip() if not stripped: @@ -92,15 +85,10 @@ def _infer_cell(raw: str) -> object: if lower in _BOOL_FALSE: return False - # --- datetime ----------------------------------------------------- - if DATE_LIKE_RE.match(stripped): - for fmt in _DATETIME_FMTS: - try: - return datetime.strptime(stripped, fmt) - except ValueError: - continue - # --- string fallback ---------------------------------------------- + # Date-like strings intentionally fall through to here (see docstring): + # kept as text so analyze_mixed_dates can see the raw format and flag a + # mixed-format column instead of it being silently coerced away. return stripped diff --git a/datascope/reports/html.py b/datascope/reports/html.py index db5ba3b..c40da98 100644 --- a/datascope/reports/html.py +++ b/datascope/reports/html.py @@ -10,6 +10,7 @@ import base64 import datetime import html +import os from collections import defaultdict from pathlib import Path from typing import Any @@ -107,7 +108,16 @@ def write_html( filename = source_metadata.get("filename", "unknown") rows = source_metadata.get("row_count", "?") cols = source_metadata.get("column_count", "?") - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + # Honor SOURCE_DATE_EPOCH (reproducible-builds standard) so regenerated + # sample reports are byte-identical; a bare datetime.now() here makes the + # generator tag change every run and defeats any byte-lock on the output. + _sde = os.environ.get("SOURCE_DATE_EPOCH") + _dt = ( + datetime.datetime.fromtimestamp(int(_sde), tz=datetime.timezone.utc) + if _sde + else datetime.datetime.now() + ) + now = _dt.strftime("%Y-%m-%d %H:%M") counts = severity_counts(findings) total = sum(counts.values()) diff --git a/pyproject.toml b/pyproject.toml index b6d26e9..866f450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "datascope-dq" -version = "2.3.4" +version = "2.4.0" description = "Data quality diagnostics for tabular datasets — surfaces hidden problems in plain English" readme = "README.md" requires-python = ">=3.10" diff --git a/samples/output/sample_mixed_types_annotated.xlsx b/samples/output/sample_mixed_types_annotated.xlsx index 05b021a..8d489b5 100644 Binary files a/samples/output/sample_mixed_types_annotated.xlsx and b/samples/output/sample_mixed_types_annotated.xlsx differ diff --git a/samples/output/sample_mixed_types_diagnostic.html b/samples/output/sample_mixed_types_diagnostic.html index b606fb6..a21545f 100644 --- a/samples/output/sample_mixed_types_diagnostic.html +++ b/samples/output/sample_mixed_types_diagnostic.html @@ -3,8 +3,8 @@ - - + + datascope diagnostic — sample_mixed_types.xlsx