aice-data-quality 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aice_data_quality-0.1.0.dist-info/METADATA +79 -0
- aice_data_quality-0.1.0.dist-info/RECORD +39 -0
- aice_data_quality-0.1.0.dist-info/WHEEL +4 -0
- aice_data_quality-0.1.0.dist-info/entry_points.txt +2 -0
- dq/__init__.py +19 -0
- dq/checks/__init__.py +30 -0
- dq/checks/accepted_values.py +57 -0
- dq/checks/base.py +102 -0
- dq/checks/not_null.py +41 -0
- dq/checks/parses_as.py +87 -0
- dq/checks/row_count.py +66 -0
- dq/checks/schema.py +58 -0
- dq/checks/unique.py +51 -0
- dq/cli.py +70 -0
- dq/client.py +302 -0
- dq/config.py +56 -0
- dq/contract.py +196 -0
- dq/dataset.py +30 -0
- dq/dbt/__init__.py +3 -0
- dq/dbt/artifacts.py +114 -0
- dq/dbt/publish.py +117 -0
- dq/errors.py +35 -0
- dq/history.py +17 -0
- dq/privacy.py +57 -0
- dq/py.typed +0 -0
- dq/sinks/__init__.py +8 -0
- dq/sinks/base.py +12 -0
- dq/sinks/http.py +55 -0
- dq/sinks/log.py +26 -0
- dq/sinks/spool.py +85 -0
- dq/sources/__init__.py +22 -0
- dq/sources/_frame.py +102 -0
- dq/sources/base.py +14 -0
- dq/sources/csv.py +52 -0
- dq/sources/dataframe.py +22 -0
- dq/sources/excel.py +98 -0
- dq/sources/sql.py +127 -0
- dq/suites.py +192 -0
- dq/telemetry.py +34 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aice-data-quality
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Data quality SDK: checks, one result contract, one destination
|
|
5
|
+
Author-email: Adanian Labs <engineering@adanianlabs.io>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: <3.14,>=3.10
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Requires-Dist: pyyaml>=6
|
|
14
|
+
Provides-Extra: dbt
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
17
|
+
Requires-Dist: jsonschema[format]>=4.21; extra == 'dev'
|
|
18
|
+
Requires-Dist: openpyxl>=3.1; extra == 'dev'
|
|
19
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'dev'
|
|
20
|
+
Requires-Dist: opentelemetry-sdk>=1.20; extra == 'dev'
|
|
21
|
+
Requires-Dist: pandas>=2; extra == 'dev'
|
|
22
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
24
|
+
Requires-Dist: sqlalchemy>=2; extra == 'dev'
|
|
25
|
+
Provides-Extra: excel
|
|
26
|
+
Requires-Dist: openpyxl>=3.1; extra == 'excel'
|
|
27
|
+
Requires-Dist: pandas>=2; extra == 'excel'
|
|
28
|
+
Provides-Extra: http
|
|
29
|
+
Requires-Dist: httpx>=0.27; extra == 'http'
|
|
30
|
+
Provides-Extra: otel
|
|
31
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
32
|
+
Provides-Extra: pandas
|
|
33
|
+
Requires-Dist: pandas>=2; extra == 'pandas'
|
|
34
|
+
Provides-Extra: sql
|
|
35
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'sql'
|
|
36
|
+
Requires-Dist: sqlalchemy>=2; extra == 'sql'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# aice-data-quality (SDK)
|
|
40
|
+
|
|
41
|
+
Check data, record the outcome in the result contract, send it to one destination.
|
|
42
|
+
Import name: `dq`. Produces records of contract `1.0` ([`contract/`](contract/README.md)).
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from dq import QualityClient, Dataset, Severity, checks as c
|
|
46
|
+
from dq.sources import DataFrameSource
|
|
47
|
+
|
|
48
|
+
client = QualityClient(project="my-project", environment="production") # or DQ_* env vars
|
|
49
|
+
with client.run(pipeline="uploads") as run:
|
|
50
|
+
report = run.validate(DataFrameSource(df), dataset=Dataset("finance_uploads", "statement", "bronze"),
|
|
51
|
+
owner="finance",
|
|
52
|
+
checks=[c.not_null("amount", name="amount_set", severity=Severity.BLOCK)])
|
|
53
|
+
if report.blocked:
|
|
54
|
+
raise UploadRejected(report.messages())
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Full guide: [`docs/user-guide.md`](docs/user-guide.md)
|
|
58
|
+
- Runnable example: [`examples/dataframe_example.py`](examples/dataframe_example.py)
|
|
59
|
+
- Specification (shared with the server): [`../README.md`](../README.md)
|
|
60
|
+
|
|
61
|
+
## What belongs here, and what does not
|
|
62
|
+
|
|
63
|
+
| In the SDK (runs inside each project) | In the server (`../server/`, never here) |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Sources, checks, suites, privacy rules | Storing results: schema, upserts, retention |
|
|
66
|
+
| Contract models, run outcome, dbt mapping | Authentication and token scopes |
|
|
67
|
+
| Sinks: `LogSink`, `HttpSink` (the server's client), the local spool | Queries, history for `vs_previous`, dataset and check listings |
|
|
68
|
+
| `SqlSource`, which reads the project's own data tables | Alert routing, UI |
|
|
69
|
+
|
|
70
|
+
The SDK has no results database and no results DDL. `SqlSource` is the only code that talks to a database,
|
|
71
|
+
and only to read data being checked.
|
|
72
|
+
|
|
73
|
+
## Develop
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
docker compose up -d --wait # sample Postgres on :55432 for SqlSource tests only
|
|
77
|
+
uv sync --extra dev
|
|
78
|
+
uv run pytest # SqlSource tests skip themselves if the database is down
|
|
79
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
dq/__init__.py,sha256=Qx3tGTck4D3P-TfdTJY9yv32it96KDpywlFSHT3hYnM,833
|
|
2
|
+
dq/cli.py,sha256=w-Gee9tKob5FDZjyORKxA47u0Zift-Jq6Nd34jl4n5U,2498
|
|
3
|
+
dq/client.py,sha256=NvcCrpGHG-AzPU3oCIEi1pdq56Yu69rY0XsiqW1lenk,13669
|
|
4
|
+
dq/config.py,sha256=j-CK4w1SBYLCpYwJdAAh3DkmTmK3ZfuKzk6XXm8Szhs,2239
|
|
5
|
+
dq/contract.py,sha256=JxiamYeJop8AZWGC3qWeucCDFbHNMlU8YGspdeo9SWM,6262
|
|
6
|
+
dq/dataset.py,sha256=3_F38kWN1SSDdasac6ykp8oYzD1BU_eM86Tu_MpSlcM,932
|
|
7
|
+
dq/errors.py,sha256=RkuA-YmnKu3GgbX7Nn643whZxguIErxxfRCRraS3lAg,915
|
|
8
|
+
dq/history.py,sha256=jYvHkMwYGTGSdOMKYZnQSzsI00U8jPyPWr4UaI7w8nE,735
|
|
9
|
+
dq/privacy.py,sha256=vGnJGL2ykjBy_iWKIYcqaVGWVxW2BBK7OjLQf7MzHGk,1651
|
|
10
|
+
dq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
dq/suites.py,sha256=GntFKg74wkgNfYMJeEqwEcf3gY7XW-eLnc2E89qfPs0,6338
|
|
12
|
+
dq/telemetry.py,sha256=YtKgEvk71yt6ZgCpQ-vzUl9Actb2lxtLNhL5DAB4EGg,1059
|
|
13
|
+
dq/checks/__init__.py,sha256=B_MGFdT7uYLN8hMvMpUy86r9i4nT70xrcjZyC1K2-Xo,1144
|
|
14
|
+
dq/checks/accepted_values.py,sha256=ZHvJM37hKob_kvrWucuCRCVL3AME-bCbfnh-MAjc-18,2620
|
|
15
|
+
dq/checks/base.py,sha256=IBLER1ABUjXeCwZHYk7Tz5LShE2jQWkwiIEW7taSpro,3296
|
|
16
|
+
dq/checks/not_null.py,sha256=cIYJUEWu7USedEv0mUGfKQ12NGQqe8rNvBmbYXAyR0Q,1688
|
|
17
|
+
dq/checks/parses_as.py,sha256=2o1T4CRiQxvoHvBCMYjN8m8jipDaSValWnnthxZ4rOw,3665
|
|
18
|
+
dq/checks/row_count.py,sha256=L9ERmo-Jh47sTPl9SlJuidoYAeK3IOIDviizs4PIpO4,3238
|
|
19
|
+
dq/checks/schema.py,sha256=I1tx8Ct-NjoqH6_2mGyj3XYFn6sU12ZSm3shnphv7jA,2684
|
|
20
|
+
dq/checks/unique.py,sha256=A6JuwZ2PPDZ3hE0tWiZlLbPji9PQYbj9QCSDXTHlnHQ,2297
|
|
21
|
+
dq/dbt/__init__.py,sha256=GEyKJbNqfqfef19yVRXmo04n1mzZocQtbsZJrHjXn1Y,84
|
|
22
|
+
dq/dbt/artifacts.py,sha256=NK8v00_4UBzTSUSIabChfQrGe-Q4IKWzIiRAFk2U7dM,3396
|
|
23
|
+
dq/dbt/publish.py,sha256=52ZDk_IPipnBPiGxQ4khHuPLgM6_901OtfOFr22LYO8,5909
|
|
24
|
+
dq/sinks/__init__.py,sha256=seaI7CSGkOaP2-N4Y4gKbf_8nrBtl6UkTkypfNe3r7E,188
|
|
25
|
+
dq/sinks/base.py,sha256=8z9DPUuMyAaMERRY9412GTeuPjVWQBooPXpjQtv_Hao,349
|
|
26
|
+
dq/sinks/http.py,sha256=jdgK9Eqdpt8rifUIQBWKYPGQCQprNaCiHB95t-eqcwQ,2328
|
|
27
|
+
dq/sinks/log.py,sha256=LSdFwDj-p2bgaDqdCRzt-bFUmPLbNPtGZzr471Yjrqg,788
|
|
28
|
+
dq/sinks/spool.py,sha256=BMv18F0A02kdakEdsAr-Mq1LHKYyef7PuHBWINIONUc,3229
|
|
29
|
+
dq/sources/__init__.py,sha256=41rDHbkQc1_32Z4zublIL9uxLVPTFlO9qEHBFUpxUsc,667
|
|
30
|
+
dq/sources/_frame.py,sha256=PgMrrhzL1vp00vpPBXMQlbSnf7M3KJzHE_X_oVGOl6w,3714
|
|
31
|
+
dq/sources/base.py,sha256=PW99ddBnc9oY-7GxsXFgZK60617drqhROkQvfdwM5XQ,366
|
|
32
|
+
dq/sources/csv.py,sha256=Cr8cnXFBrcURYipgh7ZC4pNdKoeLZ623suSf24PFlK0,2093
|
|
33
|
+
dq/sources/dataframe.py,sha256=Mrms18ttKL8wf5kClUcFXwloOxxKBFhy2mjBYLsdVRY,668
|
|
34
|
+
dq/sources/excel.py,sha256=5_SbLawqW47R-fTgOHQ7UFaK41qpXrsh1FHgoWTb2Lo,4134
|
|
35
|
+
dq/sources/sql.py,sha256=ydcsq6ke7-7SO2zJktVIocTCN19sdDNzwCeHsmoDI6I,5663
|
|
36
|
+
aice_data_quality-0.1.0.dist-info/METADATA,sha256=ZLw8Kr0PEhniG1K8sTdTeh64NqBFzi1qbkbbn7oh_Cw,3272
|
|
37
|
+
aice_data_quality-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
38
|
+
aice_data_quality-0.1.0.dist-info/entry_points.txt,sha256=U4tDvOjCv_IMzrDd3ethbmBPR3UeBP2doZZxOlykK6o,35
|
|
39
|
+
aice_data_quality-0.1.0.dist-info/RECORD,,
|
dq/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Data quality SDK. Produces records of result contract v1.0 (see contract/)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "0.1.0"
|
|
5
|
+
CONTRACT_VERSIONS = ("1.0",)
|
|
6
|
+
|
|
7
|
+
from . import checks # noqa: E402
|
|
8
|
+
from .client import QualityClient, Report, Run # noqa: E402
|
|
9
|
+
from .contract import ResultRecord, RunRecord, Severity, Status # noqa: E402
|
|
10
|
+
from .dataset import Dataset # noqa: E402
|
|
11
|
+
from .errors import (ConfigError, DQError, MissingColumn, SourceError, SuiteError, # noqa: E402
|
|
12
|
+
UnsupportedCheck)
|
|
13
|
+
from .suites import Suite # noqa: E402
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"QualityClient", "Run", "Report", "Dataset", "Severity", "Status", "Suite", "checks",
|
|
17
|
+
"RunRecord", "ResultRecord", "DQError", "ConfigError", "SuiteError", "SourceError",
|
|
18
|
+
"UnsupportedCheck", "MissingColumn", "__version__", "CONTRACT_VERSIONS",
|
|
19
|
+
]
|
dq/checks/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Check constructors (`from dq import checks as c`) and evaluator registries."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from . import accepted_values as _accepted_values
|
|
5
|
+
from . import not_null as _not_null
|
|
6
|
+
from . import parses_as as _parses_as
|
|
7
|
+
from . import row_count as _row_count
|
|
8
|
+
from . import schema as _schema
|
|
9
|
+
from . import unique as _unique
|
|
10
|
+
from .base import Check, EvalContext, Measurement
|
|
11
|
+
|
|
12
|
+
schema = _schema.build
|
|
13
|
+
not_null = _not_null.build
|
|
14
|
+
parses_as = _parses_as.build
|
|
15
|
+
accepted_values = _accepted_values.build
|
|
16
|
+
unique = _unique.build
|
|
17
|
+
row_count = _row_count.build
|
|
18
|
+
|
|
19
|
+
FRAME_EVALUATORS = {
|
|
20
|
+
"schema": _schema.evaluate, "not_null": _not_null.frame, "parses_as": _parses_as.frame,
|
|
21
|
+
"accepted_values": _accepted_values.frame, "unique": _unique.frame,
|
|
22
|
+
"row_count": _row_count.frame,
|
|
23
|
+
}
|
|
24
|
+
SQL_EVALUATORS = {
|
|
25
|
+
"schema": _schema.evaluate, "not_null": _not_null.sql,
|
|
26
|
+
"accepted_values": _accepted_values.sql, "unique": _unique.sql, "row_count": _row_count.sql,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
__all__ = ["Check", "EvalContext", "Measurement", "schema", "not_null", "parses_as",
|
|
30
|
+
"accepted_values", "unique", "row_count", "FRAME_EVALUATORS", "SQL_EVALUATORS"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Sequence
|
|
4
|
+
|
|
5
|
+
from ..contract import Severity, Status
|
|
6
|
+
from .base import Check, EvalContext, KeySpec, Measurement, make_check, rows_have, rows_hint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build(column: str, values: Sequence, case_sensitive: bool = True, *, name: str,
|
|
10
|
+
severity: Severity = Severity.FLAG, dimension: Optional[str] = None,
|
|
11
|
+
key: KeySpec = None) -> Check:
|
|
12
|
+
vals = tuple(str(v) for v in values)
|
|
13
|
+
return make_check("accepted_values", name=name, severity=severity, dimension=dimension,
|
|
14
|
+
default_dimension="validity", key=key,
|
|
15
|
+
params={"column": column, "values": vals, "case_sensitive": case_sensitive},
|
|
16
|
+
expected={"column": column, "values": list(vals), "case_sensitive": case_sensitive})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _result(col: str, total: int, bad: int, keys: list[str]) -> Measurement:
|
|
20
|
+
return Measurement(
|
|
21
|
+
status=Status.FAIL if bad else Status.PASS, rows_evaluated=total, failing_count=bad,
|
|
22
|
+
observed_value=bad, failing_keys=keys,
|
|
23
|
+
message=(f"{rows_have(bad)} a value in '{col}' that is not in the accepted list"
|
|
24
|
+
f"{rows_hint(keys, bad)}.") if bad else None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def frame(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
28
|
+
p = check.params
|
|
29
|
+
src.require(p["column"])
|
|
30
|
+
series = src.df[p["column"]]
|
|
31
|
+
null = src.null_mask(p["column"]).tolist()
|
|
32
|
+
ok = set(p["values"] if p["case_sensitive"] else (v.lower() for v in p["values"]))
|
|
33
|
+
positions = []
|
|
34
|
+
for i, (v, is_null) in enumerate(zip(series.tolist(), null)):
|
|
35
|
+
if is_null:
|
|
36
|
+
continue
|
|
37
|
+
s = str(v) if p["case_sensitive"] else str(v).lower()
|
|
38
|
+
if s not in ok:
|
|
39
|
+
positions.append(i)
|
|
40
|
+
keys = src.keys_at(positions[: ctx.key_limit], check)
|
|
41
|
+
return _result(p["column"], len(series), len(positions), keys)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sql(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
45
|
+
p = check.params
|
|
46
|
+
col = src.col(p["column"])
|
|
47
|
+
expr = f"cast({col} as text)" if p["case_sensitive"] else f"lower(cast({col} as text))"
|
|
48
|
+
values = p["values"] if p["case_sensitive"] else tuple(v.lower() for v in p["values"])
|
|
49
|
+
params = {f"_dq_v{i}": v for i, v in enumerate(values)}
|
|
50
|
+
if values:
|
|
51
|
+
bad_cond = f"{col} is not null and {expr} not in ({', '.join(':' + k for k in params)})"
|
|
52
|
+
else:
|
|
53
|
+
bad_cond = f"{col} is not null"
|
|
54
|
+
total, bad = src.one(
|
|
55
|
+
f"select count(*), count(*) filter (where {bad_cond}) from {src.base()}", params, ctx=ctx)
|
|
56
|
+
keys = src.keys(bad_cond, check, ctx, params) if bad else []
|
|
57
|
+
return _result(p["column"], total, bad, keys)
|
dq/checks/base.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Check definition, measurement and shared helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
from ..contract import DIMENSIONS, Severity, Status
|
|
9
|
+
from ..errors import SuiteError
|
|
10
|
+
|
|
11
|
+
_SNAKE = re.compile(r"^[a-z0-9_]+$")
|
|
12
|
+
|
|
13
|
+
KeySpec = Union[None, str, Sequence[str]]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Check:
|
|
18
|
+
type: str
|
|
19
|
+
name: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
dimension: str
|
|
22
|
+
key: Optional[tuple[str, ...]] # None: default (row number / primary key)
|
|
23
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
expected: Optional[dict[str, Any]] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class EvalContext:
|
|
29
|
+
key_limit: int = 100
|
|
30
|
+
timeout_s: float = 60.0
|
|
31
|
+
history: list[float] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Measurement:
|
|
36
|
+
status: Status
|
|
37
|
+
rows_evaluated: Optional[int] = None
|
|
38
|
+
failing_count: Optional[int] = None
|
|
39
|
+
observed_value: Union[int, float, str, None] = None
|
|
40
|
+
failing_keys: list[str] = field(default_factory=list)
|
|
41
|
+
message: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_check(
|
|
45
|
+
type: str,
|
|
46
|
+
*,
|
|
47
|
+
name: str,
|
|
48
|
+
severity: Union[Severity, str],
|
|
49
|
+
dimension: Optional[str],
|
|
50
|
+
default_dimension: str,
|
|
51
|
+
key: KeySpec,
|
|
52
|
+
params: dict[str, Any],
|
|
53
|
+
expected: Optional[dict[str, Any]] = None,
|
|
54
|
+
) -> Check:
|
|
55
|
+
if not isinstance(name, str) or not _SNAKE.match(name):
|
|
56
|
+
raise SuiteError(f"check name {name!r} must be lowercase snake case (^[a-z0-9_]+$)")
|
|
57
|
+
try:
|
|
58
|
+
sev = Severity(severity)
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise SuiteError(f"check {name!r}: severity must be block, flag or warn") from None
|
|
61
|
+
dim = dimension or default_dimension
|
|
62
|
+
if dim not in DIMENSIONS:
|
|
63
|
+
raise SuiteError(f"check {name!r}: unknown dimension {dim!r}")
|
|
64
|
+
return Check(type=type, name=name, severity=sev, dimension=dim,
|
|
65
|
+
key=normalise_key(key), params=params, expected=expected)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def normalise_key(key: KeySpec) -> Optional[tuple[str, ...]]:
|
|
69
|
+
if key is None or key == "row":
|
|
70
|
+
return None
|
|
71
|
+
if isinstance(key, str):
|
|
72
|
+
return (key,)
|
|
73
|
+
keys = tuple(key)
|
|
74
|
+
if not keys or not all(isinstance(k, str) and k for k in keys):
|
|
75
|
+
raise SuiteError("key must be 'row', a column name or a list of column names")
|
|
76
|
+
return keys
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def as_columns(columns: Union[str, Sequence[str]], what: str = "columns") -> tuple[str, ...]:
|
|
80
|
+
cols = (columns,) if isinstance(columns, str) else tuple(columns)
|
|
81
|
+
if not cols or not all(isinstance(c, str) and c for c in cols):
|
|
82
|
+
raise SuiteError(f"{what} must be a non-empty list of column names")
|
|
83
|
+
return cols
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def rows_hint(keys: Sequence[str], total: int, shown: int = 10) -> str:
|
|
87
|
+
"""' (rows 14, 27, 301)' when every key is a row number; empty otherwise, so a
|
|
88
|
+
message never carries key values from the data."""
|
|
89
|
+
if not keys or not all(k.startswith("row:") for k in keys):
|
|
90
|
+
return ""
|
|
91
|
+
nums = [k[4:] for k in keys[:shown]]
|
|
92
|
+
more = f" and {total - len(nums)} more" if total > len(nums) else ""
|
|
93
|
+
return f" (rows {', '.join(nums)}{more})"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plural(n: int, word: str) -> str:
|
|
97
|
+
return f"{n} {word}" if n == 1 else f"{n} {word}s"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def rows_have(n: int) -> str:
|
|
101
|
+
"""'1 row has' / '3 rows have'."""
|
|
102
|
+
return f"{n} row has" if n == 1 else f"{n} rows have"
|
dq/checks/not_null.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from ..contract import Severity, Status
|
|
6
|
+
from .base import Check, EvalContext, KeySpec, Measurement, make_check, rows_have, rows_hint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build(column: str, *, name: str, severity: Severity = Severity.FLAG,
|
|
10
|
+
dimension: Optional[str] = None, key: KeySpec = None) -> Check:
|
|
11
|
+
return make_check("not_null", name=name, severity=severity, dimension=dimension,
|
|
12
|
+
default_dimension="completeness", key=key,
|
|
13
|
+
params={"column": column}, expected={"column": column, "null_share": 0})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _result(col: str, total: int, nulls: int, keys: list[str]) -> Measurement:
|
|
17
|
+
return Measurement(
|
|
18
|
+
status=Status.FAIL if nulls else Status.PASS,
|
|
19
|
+
rows_evaluated=total, failing_count=nulls,
|
|
20
|
+
observed_value=(nulls / total) if total else 0.0, failing_keys=keys,
|
|
21
|
+
message=(f"{rows_have(nulls)} no value in '{col}'"
|
|
22
|
+
f"{rows_hint(keys, nulls)}.") if nulls else None,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def frame(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
27
|
+
col = check.params["column"]
|
|
28
|
+
mask = src.null_mask(col)
|
|
29
|
+
positions = [i for i, f in enumerate(mask.tolist()) if f]
|
|
30
|
+
keys = src.keys_at(positions[: ctx.key_limit], check)
|
|
31
|
+
return _result(col, len(mask), len(positions), keys)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sql(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
35
|
+
col = src.col(check.params["column"])
|
|
36
|
+
total, nulls = src.one(
|
|
37
|
+
f"select count(*), count(*) filter (where {col} is null) from {src.base()}", ctx=ctx)
|
|
38
|
+
keys = []
|
|
39
|
+
if nulls:
|
|
40
|
+
keys = src.keys(f"{col} is null", check, ctx)
|
|
41
|
+
return _result(check.params["column"], total, nulls, keys)
|
dq/checks/parses_as.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import decimal
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from ..contract import Severity, Status
|
|
9
|
+
from ..errors import SuiteError
|
|
10
|
+
from .base import Check, EvalContext, KeySpec, Measurement, make_check, rows_have, rows_hint
|
|
11
|
+
|
|
12
|
+
TYPES = ("date", "datetime", "integer", "decimal")
|
|
13
|
+
DEFAULT_FORMATS = {
|
|
14
|
+
"date": ("%Y-%m-%d",),
|
|
15
|
+
"datetime": ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"),
|
|
16
|
+
}
|
|
17
|
+
_INT = re.compile(r"^[+-]?\d+$")
|
|
18
|
+
_DEC = re.compile(r"^[+-]?(\d+(\.\d*)?|\.\d+)$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build(column: str, type: str, formats: Optional[Sequence[str]] = None,
|
|
22
|
+
allow_empty: bool = False, *, name: str, severity: Severity = Severity.FLAG,
|
|
23
|
+
dimension: Optional[str] = None, key: KeySpec = None) -> Check:
|
|
24
|
+
if type not in TYPES:
|
|
25
|
+
raise SuiteError(f"check {name!r}: parses_as type must be one of {', '.join(TYPES)}")
|
|
26
|
+
if formats and type not in DEFAULT_FORMATS:
|
|
27
|
+
raise SuiteError(f"check {name!r}: formats apply only to date and datetime")
|
|
28
|
+
fmts = tuple(formats) if formats else DEFAULT_FORMATS.get(type, ())
|
|
29
|
+
expected = {"column": column, "type": type, "allow_empty": allow_empty}
|
|
30
|
+
if fmts:
|
|
31
|
+
expected["formats"] = list(fmts)
|
|
32
|
+
return make_check("parses_as", name=name, severity=severity, dimension=dimension,
|
|
33
|
+
default_dimension="validity", key=key,
|
|
34
|
+
params={"column": column, "type": type, "formats": fmts,
|
|
35
|
+
"allow_empty": allow_empty}, expected=expected)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parses(value, type: str, formats: Sequence[str], native_dates: bool) -> bool:
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
s = value.strip()
|
|
41
|
+
if type in ("date", "datetime"):
|
|
42
|
+
fmts = list(formats)
|
|
43
|
+
if native_dates: # ExcelSource renders real date cells as ISO text
|
|
44
|
+
fmts += list(DEFAULT_FORMATS["date"]) + list(DEFAULT_FORMATS["datetime"])
|
|
45
|
+
for f in fmts:
|
|
46
|
+
try:
|
|
47
|
+
dt.datetime.strptime(s, f)
|
|
48
|
+
return True
|
|
49
|
+
except ValueError:
|
|
50
|
+
pass
|
|
51
|
+
return False
|
|
52
|
+
if type == "integer":
|
|
53
|
+
return bool(_INT.match(s))
|
|
54
|
+
return bool(_DEC.match(s))
|
|
55
|
+
if isinstance(value, bool):
|
|
56
|
+
return False
|
|
57
|
+
if type in ("date", "datetime"):
|
|
58
|
+
return isinstance(value, (dt.date, dt.datetime)) # includes pandas Timestamp
|
|
59
|
+
if type == "integer":
|
|
60
|
+
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
|
61
|
+
return isinstance(value, (int, float, decimal.Decimal)) and value == value # not NaN
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def frame(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
65
|
+
p = check.params
|
|
66
|
+
col = p["column"]
|
|
67
|
+
src.require(col)
|
|
68
|
+
values = src.df[col].tolist()
|
|
69
|
+
null = src.null_mask(col).tolist()
|
|
70
|
+
native = getattr(src, "native_dates", False)
|
|
71
|
+
positions = [
|
|
72
|
+
i for i, (v, is_null) in enumerate(zip(values, null))
|
|
73
|
+
if (is_null and not p["allow_empty"])
|
|
74
|
+
or (not is_null and not parses(v, p["type"], p["formats"], native))
|
|
75
|
+
]
|
|
76
|
+
keys = src.keys_at(positions[: ctx.key_limit], check)
|
|
77
|
+
bad = len(positions)
|
|
78
|
+
fmt = f" in format {', '.join(p['formats'])}" if p["formats"] else ""
|
|
79
|
+
return Measurement(
|
|
80
|
+
status=Status.FAIL if bad else Status.PASS, rows_evaluated=len(values),
|
|
81
|
+
failing_count=bad, observed_value=bad, failing_keys=keys,
|
|
82
|
+
message=(f"{rows_have(bad)} a '{col}' that is empty or not a valid "
|
|
83
|
+
f"{p['type']}{fmt}{rows_hint(keys, bad)}.") if bad else None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Typed database columns are checked by `schema`; parses_as has no SQL pushdown.
|
|
87
|
+
sql = None
|
dq/checks/row_count.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from ..contract import Severity, Status
|
|
7
|
+
from ..errors import SuiteError
|
|
8
|
+
from ..history import MIN_HISTORY
|
|
9
|
+
from .base import Check, EvalContext, KeySpec, Measurement, make_check
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build(min: Optional[int] = None, max: Optional[int] = None,
|
|
13
|
+
vs_previous: Optional[float] = None, history: int = 14, *, name: str,
|
|
14
|
+
severity: Severity = Severity.FLAG, dimension: Optional[str] = None,
|
|
15
|
+
key: KeySpec = None) -> Check:
|
|
16
|
+
if min is None and max is None and vs_previous is None:
|
|
17
|
+
raise SuiteError(f"check {name!r}: row_count needs min, max or vs_previous")
|
|
18
|
+
if vs_previous is not None and vs_previous < 0:
|
|
19
|
+
raise SuiteError(f"check {name!r}: vs_previous must be a non-negative fraction")
|
|
20
|
+
if history < MIN_HISTORY:
|
|
21
|
+
raise SuiteError(f"check {name!r}: history must be at least {MIN_HISTORY}")
|
|
22
|
+
expected = {k: v for k, v in (("min", min), ("max", max), ("vs_previous", vs_previous)) if v is not None}
|
|
23
|
+
if vs_previous is not None:
|
|
24
|
+
expected["history"] = history
|
|
25
|
+
return make_check("row_count", name=name, severity=severity, dimension=dimension,
|
|
26
|
+
default_dimension="volume", key=key,
|
|
27
|
+
params={"min": min, "max": max, "vs_previous": vs_previous, "history": history},
|
|
28
|
+
expected=expected)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def judge(count: int, check: Check, ctx: EvalContext) -> Measurement:
|
|
32
|
+
p = check.params
|
|
33
|
+
problems = []
|
|
34
|
+
if p["min"] is not None and count < p["min"]:
|
|
35
|
+
problems.append(f"Row count {count} is below the minimum {p['min']}.")
|
|
36
|
+
if p["max"] is not None and count > p["max"]:
|
|
37
|
+
problems.append(f"Row count {count} is above the maximum {p['max']}.")
|
|
38
|
+
if problems:
|
|
39
|
+
return Measurement(Status.FAIL, rows_evaluated=count, observed_value=count,
|
|
40
|
+
message=" ".join(problems))
|
|
41
|
+
if p["vs_previous"] is not None:
|
|
42
|
+
if len(ctx.history) < MIN_HISTORY:
|
|
43
|
+
return Measurement(
|
|
44
|
+
Status.SKIPPED, rows_evaluated=count, observed_value=count,
|
|
45
|
+
message=f"Not enough history to compare against ({len(ctx.history)} of "
|
|
46
|
+
f"{MIN_HISTORY} earlier values).")
|
|
47
|
+
median = statistics.median(ctx.history[: p["history"]])
|
|
48
|
+
change = abs(count - median)
|
|
49
|
+
if change > p["vs_previous"] * median:
|
|
50
|
+
pct = f"{change / median:.0%}" if median else "n/a"
|
|
51
|
+
direction = "below" if count < median else "above"
|
|
52
|
+
return Measurement(
|
|
53
|
+
Status.FAIL, rows_evaluated=count, observed_value=count,
|
|
54
|
+
message=f"Row count {count} is {pct} {direction} the median {median:g} of the "
|
|
55
|
+
f"last {min(len(ctx.history), p['history'])} runs "
|
|
56
|
+
f"(allowed change {p['vs_previous']:.0%}).")
|
|
57
|
+
return Measurement(Status.PASS, rows_evaluated=count, observed_value=count)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def frame(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
61
|
+
return judge(len(src.df), check, ctx)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sql(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
65
|
+
(count,) = src.one(f"select count(*) from {src.base()}", ctx=ctx)
|
|
66
|
+
return judge(int(count), check, ctx)
|
dq/checks/schema.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""schema: expected columns and (optionally) types. Works on any source."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Mapping, Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from ..contract import Severity, Status
|
|
7
|
+
from ..errors import UnsupportedCheck
|
|
8
|
+
from .base import Check, EvalContext, KeySpec, Measurement, as_columns, make_check
|
|
9
|
+
|
|
10
|
+
TYPE_ALIASES = {
|
|
11
|
+
"int": "integer", "integer": "integer", "bigint": "integer",
|
|
12
|
+
"float": "decimal", "number": "decimal", "numeric": "decimal", "decimal": "decimal",
|
|
13
|
+
"str": "string", "string": "string", "text": "string",
|
|
14
|
+
"bool": "boolean", "boolean": "boolean",
|
|
15
|
+
"date": "date", "datetime": "datetime", "timestamp": "datetime",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build(columns: Sequence[str], types: Optional[Mapping[str, str]] = None,
|
|
20
|
+
allow_extra: bool = True, *, name: str, severity: Severity = Severity.FLAG,
|
|
21
|
+
dimension: Optional[str] = None, key: KeySpec = None) -> Check:
|
|
22
|
+
cols = as_columns(columns)
|
|
23
|
+
norm_types: dict[str, str] = {}
|
|
24
|
+
for col, t in (types or {}).items():
|
|
25
|
+
if t not in TYPE_ALIASES:
|
|
26
|
+
raise ValueError(f"unknown type {t!r} for column {col!r}")
|
|
27
|
+
norm_types[col] = TYPE_ALIASES[t]
|
|
28
|
+
expected = {"columns": list(cols), "allow_extra": allow_extra}
|
|
29
|
+
if norm_types:
|
|
30
|
+
expected["types"] = norm_types
|
|
31
|
+
return make_check("schema", name=name, severity=severity, dimension=dimension,
|
|
32
|
+
default_dimension="conformity", key=key,
|
|
33
|
+
params={"columns": cols, "types": norm_types, "allow_extra": allow_extra},
|
|
34
|
+
expected=expected)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def evaluate(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
38
|
+
p = check.params
|
|
39
|
+
actual = src.columns()
|
|
40
|
+
if p["types"] and not getattr(src, "supports_types", True):
|
|
41
|
+
raise UnsupportedCheck("types is not supported on file sources (all cells are text); use parses_as")
|
|
42
|
+
missing = [c for c in p["columns"] if c not in actual]
|
|
43
|
+
wrong = [c for c, t in p["types"].items() if c in actual and actual[c] != t]
|
|
44
|
+
extra = [] if p["allow_extra"] else [c for c in actual if c not in p["columns"]]
|
|
45
|
+
problems = len(missing) + len(wrong) + len(extra)
|
|
46
|
+
parts = []
|
|
47
|
+
if missing:
|
|
48
|
+
parts.append(f"Missing columns: {', '.join(missing)}.")
|
|
49
|
+
if wrong:
|
|
50
|
+
parts.append("Wrong type: " + ", ".join(f"{c} (expected {p['types'][c]})" for c in wrong) + ".")
|
|
51
|
+
if extra:
|
|
52
|
+
parts.append(f"Unexpected columns: {', '.join(extra)}.")
|
|
53
|
+
return Measurement(
|
|
54
|
+
status=Status.FAIL if problems else Status.PASS,
|
|
55
|
+
failing_count=problems,
|
|
56
|
+
observed_value=", ".join(missing + wrong + extra) if problems else None,
|
|
57
|
+
message=" ".join(parts) if parts else None,
|
|
58
|
+
)
|
dq/checks/unique.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Sequence, Union
|
|
4
|
+
|
|
5
|
+
from ..contract import Severity, Status
|
|
6
|
+
from .base import Check, EvalContext, KeySpec, Measurement, as_columns, make_check, plural, rows_hint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build(columns: Union[str, Sequence[str]], *, name: str, severity: Severity = Severity.FLAG,
|
|
10
|
+
dimension: Optional[str] = None, key: KeySpec = None) -> Check:
|
|
11
|
+
cols = as_columns(columns)
|
|
12
|
+
return make_check("unique", name=name, severity=severity, dimension=dimension,
|
|
13
|
+
default_dimension="uniqueness", key=key,
|
|
14
|
+
params={"columns": cols}, expected={"columns": list(cols)})
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _result(cols: Sequence[str], total: int, dup: int, keys: list[str]) -> Measurement:
|
|
18
|
+
label = ", ".join(cols)
|
|
19
|
+
return Measurement(
|
|
20
|
+
status=Status.FAIL if dup else Status.PASS, rows_evaluated=total, failing_count=dup,
|
|
21
|
+
observed_value=dup, failing_keys=keys,
|
|
22
|
+
message=(f"{plural(dup, 'row')} share a duplicate value of ({label})"
|
|
23
|
+
f"{rows_hint(keys, dup)}.") if dup else None)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def frame(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
27
|
+
cols = list(check.params["columns"])
|
|
28
|
+
df = src.df
|
|
29
|
+
for c in cols:
|
|
30
|
+
src.require(c)
|
|
31
|
+
nonnull = ~src.null_mask(cols[0])
|
|
32
|
+
for c in cols[1:]:
|
|
33
|
+
nonnull = nonnull & ~src.null_mask(c)
|
|
34
|
+
# Duplicates among rows where every column has a value; nulls never collide.
|
|
35
|
+
dup = nonnull & df.duplicated(subset=cols, keep=False)
|
|
36
|
+
positions = [i for i, f in enumerate(dup.tolist()) if f]
|
|
37
|
+
keys = src.keys_at(positions[: ctx.key_limit], check)
|
|
38
|
+
return _result(cols, len(df), len(positions), keys)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sql(src, check: Check, ctx: EvalContext) -> Measurement:
|
|
42
|
+
cols = [src.col(c) for c in check.params["columns"]]
|
|
43
|
+
joined = ", ".join(cols)
|
|
44
|
+
nonnull = " and ".join(f"{c} is not null" for c in cols)
|
|
45
|
+
dup_groups = (f"select {joined} from {src.base()} where {nonnull} "
|
|
46
|
+
f"group by {joined} having count(*) > 1")
|
|
47
|
+
in_dup = f"{nonnull} and ({joined}) in ({dup_groups})"
|
|
48
|
+
total, dup = src.one(
|
|
49
|
+
f"select count(*), count(*) filter (where {in_dup}) from {src.base()}", ctx=ctx)
|
|
50
|
+
keys = src.keys(in_dup, check, ctx) if dup else []
|
|
51
|
+
return _result(check.params["columns"], total, dup, keys)
|