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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dq = dq.cli:main
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)