dactopus 0.2.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.
dactopus/checks.py ADDED
@@ -0,0 +1,671 @@
1
+ """Checks over the input and the canonical tables, and their findings.
2
+
3
+ A check is a function of a ClickHouse client and the configuration that
4
+ returns findings. Checks register through the entry point group
5
+ ``dactopus.checks``; the entry point's name is the check's name. The
6
+ built-in checks register the same way, in pyproject.toml.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import statistics
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from datetime import UTC, date, datetime, timedelta
17
+ from importlib.metadata import entry_points
18
+ from typing import Any, Literal
19
+ from zoneinfo import ZoneInfo
20
+
21
+ from clickhouse_connect.driver.client import Client
22
+ from clickhouse_connect.driver.exceptions import ClickHouseError
23
+
24
+ from . import library
25
+ from .build import misdated, target
26
+ from .config import Config
27
+
28
+ Severity = Literal["pass", "skip", "warn", "fail", "error"]
29
+
30
+ # From the least to the most serious.
31
+ SEVERITIES: tuple[Severity, ...] = ("pass", "skip", "warn", "fail", "error")
32
+
33
+ ENTRY_POINT_GROUP = "dactopus.checks"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Finding:
38
+ """One result of a check, in the same shape for built-in and plugin checks."""
39
+
40
+ check: str
41
+ # The table, column or test the finding is about.
42
+ object: str
43
+ severity: Severity
44
+ observed: str
45
+ expected: str
46
+ # One sentence a store owner can read.
47
+ sentence: str
48
+
49
+
50
+ Check = Callable[[Client, Config], list[Finding]]
51
+
52
+
53
+ def run_all(client: Client, config: Config) -> list[Finding]:
54
+ """Run every registered check; a check that breaks becomes an error finding."""
55
+ findings: list[Finding] = []
56
+ for entry in sorted(entry_points(group=ENTRY_POINT_GROUP), key=lambda e: e.name):
57
+ try:
58
+ check: Check = entry.load()
59
+ findings.extend(check(client, config))
60
+ except Exception as exc: # noqa: BLE001 - a plugin's fault must not hide the others
61
+ findings.append(
62
+ Finding(
63
+ check=entry.name,
64
+ object=entry.value,
65
+ severity="error",
66
+ observed=f"{type(exc).__name__}: {exc}",
67
+ expected="the check runs",
68
+ sentence=f"The check {entry.name} could not run: {exc}",
69
+ )
70
+ )
71
+ return findings
72
+
73
+
74
+ def failed(findings: list[Finding]) -> bool:
75
+ """Whether the run should exit non-zero: no findings, or a fail or an error among them.
76
+
77
+ The built-in checks always find something; none means they are not
78
+ installed.
79
+ """
80
+ return not findings or any(finding.severity in ("fail", "error") for finding in findings)
81
+
82
+
83
+ def now() -> datetime:
84
+ return datetime.now(UTC)
85
+
86
+
87
+ # dbt results: target/run_results.json with manifest.json for what each node is.
88
+
89
+ _STATUS: dict[str, Severity] = {
90
+ "pass": "pass",
91
+ "success": "pass",
92
+ "warn": "warn",
93
+ "fail": "fail",
94
+ "error": "error",
95
+ "skipped": "skip",
96
+ }
97
+
98
+ _REF = re.compile(r"""ref\(\s*['"]([^'"]+)['"]\s*\)""")
99
+
100
+ # The status of exposures and saved queries in `dbt build`: nothing ran,
101
+ # so there is nothing to report (the source of dbt 1.11.16, 2026-10-22).
102
+ _NOT_RUN = "no-op"
103
+
104
+ # What dbt says of an incremental model with `on_schema_change: fail` whose
105
+ # columns differ from the table's (dbt-core 1.11.16 with dbt-clickhouse
106
+ # 1.10.3, 2026-10-23: `status: error`, and the nodes after it skipped).
107
+ _SCHEMA_CHANGE = "The source and target schemas on this incremental model are out of sync"
108
+
109
+
110
+ def dbt_results(client: Client, config: Config) -> list[Finding]:
111
+ """The models, tests and unit tests of the last dbt run of each package."""
112
+ findings: list[Finding] = []
113
+ for package in config.packages():
114
+ where = target(config, package)
115
+ try:
116
+ results = json.loads((where / "run_results.json").read_text())
117
+ manifest = json.loads((where / "manifest.json").read_text())
118
+ except FileNotFoundError:
119
+ findings.append(
120
+ Finding(
121
+ check="dbt",
122
+ object=package,
123
+ severity="error",
124
+ observed="no dbt results",
125
+ expected="a dbt run of the package",
126
+ sentence=f"The {package} package has not been built yet: run `dactopus run`.",
127
+ )
128
+ )
129
+ continue
130
+ for result in results["results"]:
131
+ if result["status"] != _NOT_RUN:
132
+ findings.append(_dbt_finding(result, manifest, package))
133
+ return findings
134
+
135
+
136
+ def _dbt_finding(result: dict[str, Any], manifest: dict[str, Any], package: str) -> Finding:
137
+ unique_id = result["unique_id"]
138
+ kind = unique_id.split(".", 1)[0]
139
+ status = result["status"]
140
+ severity = _STATUS.get(status, "error")
141
+ message = result.get("message") or status
142
+ failures = result.get("failures")
143
+ observed = status if failures is None else f"{failures} rows"
144
+ if kind == "model":
145
+ table = manifest["nodes"][unique_id]["name"]
146
+ if severity == "error" and _SCHEMA_CHANGE in message:
147
+ sentence = (
148
+ f"{table} no longer has the columns its model builds (a release of the "
149
+ "models library changed them, or the table was changed by hand); run "
150
+ f"`dactopus run` once with `--full-refresh {package}` to rebuild it "
151
+ "from the input."
152
+ )
153
+ else:
154
+ sentence = {
155
+ "pass": f"{table} was built.",
156
+ "skip": f"{table} was not built because an earlier step failed.",
157
+ }.get(severity, f"{table} could not be built: {message}")
158
+ return Finding("dbt", table, severity, status, "built", sentence)
159
+ if kind == "unit_test":
160
+ unit = manifest["unit_tests"][unique_id]
161
+ where = f"the unit test {unit['name']} of {unit['model']}"
162
+ sentence = {
163
+ "pass": f"{_cap(where)} passes.",
164
+ "skip": f"{_cap(where)} did not run because an earlier step failed.",
165
+ "fail": f"{_cap(where)} fails: the package's logic, not the data; report it.",
166
+ }.get(severity, f"{_cap(where)} could not run: {message}")
167
+ return Finding("dbt", unit["model"], severity, status, "pass", sentence)
168
+ if kind == "test":
169
+ node = manifest["nodes"][unique_id]
170
+ return _test_finding(node, manifest, severity, status, message, failures, observed)
171
+ return Finding("dbt", unique_id, severity, status, "pass", f"{unique_id}: {message}")
172
+
173
+
174
+ def _test_finding(
175
+ node: dict[str, Any],
176
+ manifest: dict[str, Any],
177
+ severity: Severity,
178
+ status: str,
179
+ message: str,
180
+ failures: int | None,
181
+ observed: str,
182
+ ) -> Finding:
183
+ nodes = manifest["nodes"]
184
+ metadata = node.get("test_metadata")
185
+ column = node.get("column_name")
186
+ n = failures or 0
187
+ if metadata is None:
188
+ # A singular test: a query in the package's tests/ that must return no rows.
189
+ models = [nodes[d]["name"] for d in node["depends_on"]["nodes"] if d.startswith("model.")]
190
+ table = models[0] if models else node["name"]
191
+ what = node["name"].replace("_", " ")
192
+ where = f"the check {what} on {', '.join(models) or table}"
193
+ ok = f"{_cap(where)} passes."
194
+ bad = f"{_n(n, 'row')} of {table} {_verb(n, 'fails', 'fail')} the check {what}."
195
+ obj = table
196
+ else:
197
+ kind = metadata["name"]
198
+ kwargs = metadata.get("kwargs", {})
199
+ attached = node.get("attached_node")
200
+ table = nodes[attached]["name"] if attached else "a table"
201
+ obj = f"{table}.{column}" if column else table
202
+ where = f"the {kind} test on {obj}"
203
+ if kind == "not_null":
204
+ ok = f"{column} is set in every row of {table}."
205
+ bad = f"{column} is missing in {_n(n, 'row')} of {table}."
206
+ elif kind == "unique":
207
+ ok = f"No {column} repeats in {table}."
208
+ bad = (
209
+ f"{_n(n, 'value')} of {column} {_verb(n, 'appears', 'appear')} in more "
210
+ f"than one row of {table}."
211
+ )
212
+ elif kind == "accepted_values":
213
+ values = ", ".join(str(v) for v in kwargs.get("values", []))
214
+ ok = f"Every {column} of {table} is one of {values}."
215
+ bad = (
216
+ f"{_n(n, 'value')} of {column} in {table} {_verb(n, 'is', 'are')} not among "
217
+ f"{values}."
218
+ )
219
+ elif kind == "relationships":
220
+ match = _REF.search(str(kwargs.get("to", "")))
221
+ to = match.group(1) if match else str(kwargs.get("to"))
222
+ field = kwargs.get("field", column)
223
+ ok = f"Every {column} of {table} exists as {field} in {to}."
224
+ bad = (
225
+ f"{_n(n, 'row')} of {table} {_verb(n, 'has', 'have')} no matching {field} in {to}."
226
+ )
227
+ else:
228
+ ok = f"{_cap(where)} passes."
229
+ bad = f"{_n(n, 'row')} {_verb(n, 'fails', 'fail')} {where}."
230
+ sentence = {
231
+ "pass": ok,
232
+ "warn": bad,
233
+ "fail": bad,
234
+ "skip": f"{_cap(where)} did not run because an earlier step failed.",
235
+ }.get(severity, f"{_cap(where)} could not run: {message}")
236
+ return Finding("dbt", obj, severity, observed, "0 rows", sentence)
237
+
238
+
239
+ def _cap(text: str) -> str:
240
+ return text[:1].upper() + text[1:]
241
+
242
+
243
+ def _n(count: int, noun: str) -> str:
244
+ return f"{count:,} {noun}{'' if count == 1 else 's'}"
245
+
246
+
247
+ def _verb(count: int, singular: str, plural: str) -> str:
248
+ return singular if count == 1 else plural
249
+
250
+
251
+ # Input freshness: target/sources.json of `dbt source freshness`; the package
252
+ # sets the thresholds in its sources.yml.
253
+
254
+ _FRESHNESS: dict[str, Severity] = {"pass": "pass", "warn": "warn", "error": "error"}
255
+
256
+
257
+ def freshness(client: Client, config: Config) -> list[Finding]:
258
+ """How old the latest input row of each package is, against the package's thresholds."""
259
+ findings: list[Finding] = []
260
+ for package in config.packages():
261
+ path = target(config, package) / "sources.json"
262
+ try:
263
+ results = json.loads(path.read_text())["results"]
264
+ except FileNotFoundError:
265
+ findings.append(
266
+ Finding(
267
+ check="freshness",
268
+ object=package,
269
+ severity="error",
270
+ observed="no freshness results",
271
+ expected="a freshness run of the package",
272
+ sentence=f"The freshness of the {package} input is unknown: "
273
+ "run `dactopus run`.",
274
+ )
275
+ )
276
+ continue
277
+ if not results:
278
+ # dbt 1.11.16 writes an empty list when the source table is missing
279
+ # (2026-10-22).
280
+ findings.append(
281
+ Finding(
282
+ check="freshness",
283
+ object=package,
284
+ severity="error",
285
+ observed="no source table",
286
+ expected="the input table of the package",
287
+ sentence=f"The freshness of the {package} input could not be measured: "
288
+ "its input table is missing; see the dbt output and run `dactopus run`.",
289
+ )
290
+ )
291
+ for result in results:
292
+ findings.append(_freshness_finding(package, result))
293
+ return findings
294
+
295
+
296
+ def _freshness_finding(package: str, result: dict[str, Any]) -> Finding:
297
+ table = result["unique_id"].rsplit(".", 1)[-1]
298
+ status = result["status"]
299
+ severity = _FRESHNESS.get(status, "error")
300
+ criteria = result.get("criteria") or {}
301
+ warn_after = _period(criteria.get("warn_after"))
302
+ error_after = _period(criteria.get("error_after"))
303
+ expected = f"within {warn_after or 'any time'}"
304
+ if error_after:
305
+ expected += f", {error_after} at most"
306
+ if severity == "error" and status != "error":
307
+ return Finding(
308
+ "freshness",
309
+ table,
310
+ "error",
311
+ status,
312
+ expected,
313
+ f"The freshness of {table} ({package}) could not be measured: "
314
+ f"{result.get('error') or status}",
315
+ )
316
+ latest = datetime.fromisoformat(result["max_loaded_at"])
317
+ age = ago(result["max_loaded_at_time_ago_in_s"])
318
+ observed = f"latest {latest:%Y-%m-%d %H:%M} UTC, {age} ago"
319
+ if severity == "pass":
320
+ sentence = (
321
+ f"The {package} input is current: the latest row of {table} is {age} old "
322
+ f"({latest:%Y-%m-%d})."
323
+ )
324
+ elif severity == "warn":
325
+ sentence = (
326
+ f"No new rows of {table} ({package}) for {age}, the latest from "
327
+ f"{latest:%Y-%m-%d}; the package expects them within {warn_after}."
328
+ )
329
+ else:
330
+ sentence = (
331
+ f"No new rows of {table} ({package}) for {age}, the latest from "
332
+ f"{latest:%Y-%m-%d}; the package allows {error_after} at most. "
333
+ "Check that the source still exports and the load runs."
334
+ )
335
+ return Finding("freshness", table, severity, observed, expected, sentence)
336
+
337
+
338
+ def _period(spec: dict[str, Any] | None) -> str | None:
339
+ if not spec or spec.get("count") is None:
340
+ return None
341
+ count, period = spec["count"], spec["period"]
342
+ return f"{count} {period}{'s' if count != 1 else ''}"
343
+
344
+
345
+ def ago(seconds: float) -> str:
346
+ """An age in words: a minute, 5 minutes, 3 hours, 2 days."""
347
+ minutes = seconds / 60
348
+ if minutes < 2:
349
+ return "a minute"
350
+ if minutes < 120:
351
+ return f"{minutes:.0f} minutes"
352
+ hours = minutes / 60
353
+ if hours < 48:
354
+ return f"{hours:.0f} hours"
355
+ return f"{hours / 24:.0f} days"
356
+
357
+
358
+ # Rows per day: the tables each package declares as daily in its
359
+ # deployment.yml, counted by their date column, over the last 7 days that
360
+ # are over against the 4 weeks before. A day without rows is a finding only
361
+ # in a table the package marks as a gap: a day without events or orders is
362
+ # a gap, a day without refunds is not.
363
+
364
+ WEEK = 7
365
+ PAST_WEEKS = 4
366
+
367
+
368
+ @dataclass(frozen=True)
369
+ class Volume:
370
+ """Rows per day of one table over its last 7 days that are over, with past weeks."""
371
+
372
+ package: str
373
+ table: str
374
+ first: date | None
375
+ # The day the week ends at; None when the table has no day that is over.
376
+ latest: date | None
377
+ # The last 7 days ending at latest, 0 for a day without rows.
378
+ days: dict[date, int]
379
+ # Per day of the week: the median of the same weekday over the 4 weeks
380
+ # before, None when the table has no history that far.
381
+ typical: dict[date, int | None]
382
+ # Why the table could not be counted, when it could not.
383
+ error: str | None = None
384
+
385
+ @property
386
+ def gaps(self) -> list[date]:
387
+ """Days without rows where the same weekday had rows, or no history to say."""
388
+ return [day for day, rows in self.days.items() if rows == 0 and self.typical[day] != 0]
389
+
390
+ @property
391
+ def quiet(self) -> list[date]:
392
+ """Days without rows, as the same weekday in the weeks before: a small shop's usual."""
393
+ return [day for day, rows in self.days.items() if rows == 0 and self.typical[day] == 0]
394
+
395
+
396
+ def volumes(client: Client, config: Config, at: datetime) -> list[Volume]:
397
+ """Rows per day of each counted table of the configured packages."""
398
+ found: list[Volume] = []
399
+ database = config.clickhouse.database
400
+ for package in config.packages():
401
+ declared = _deployment(config, package)
402
+ last = _last_day(config, declared, at)
403
+ for counted in declared.daily:
404
+ table = counted.table
405
+ try:
406
+ found.append(_volume(client, database, package, table, counted.date, last))
407
+ except ClickHouseError as exc:
408
+ found.append(Volume(package, table, None, None, {}, {}, _first_line(exc)))
409
+ return found
410
+
411
+
412
+ def _deployment(config: Config, package: str) -> library.Deployment:
413
+ return library.deployment(config.models, library.resolve(config.models), package)
414
+
415
+
416
+ def _last_day(config: Config, declared: library.Deployment, at: datetime) -> date | None:
417
+ """Yesterday in store.timezone; None for a package whose days are all over.
418
+
419
+ A package without a time zone variable has days that end where its
420
+ source says (GA4 is read in daily tables, each a day that is over). A
421
+ package with one is built in store.timezone, and its day is over at
422
+ midnight there.
423
+ """
424
+ if "time_zone" not in declared.vars:
425
+ return None
426
+ return at.astimezone(ZoneInfo(config.store.timezone)).date() - timedelta(days=1)
427
+
428
+
429
+ def _first_line(exc: Exception) -> str:
430
+ return str(exc).splitlines()[0]
431
+
432
+
433
+ def _volume(
434
+ client: Client, database: str, package: str, table: str, column: str, last: date | None
435
+ ) -> Volume:
436
+ qualified = f"`{database}`.`{table}`"
437
+ rows, first, latest = client.query(
438
+ f"SELECT count(), min(`{column}`), max(`{column}`) FROM {qualified}"
439
+ ).result_rows[0]
440
+ if not rows:
441
+ return Volume(package, table, None, None, {}, {})
442
+ end = latest if last is None else last
443
+ if end < first:
444
+ # Rows only for a day that is not over yet.
445
+ return Volume(package, table, first, None, {}, {})
446
+ span = WEEK * (PAST_WEEKS + 1)
447
+ counted = dict(
448
+ client.query(
449
+ f"SELECT `{column}` AS day, count() FROM {qualified} "
450
+ f"WHERE `{column}` > {{end:Date}} - INTERVAL {span} DAY "
451
+ f"AND `{column}` <= {{end:Date}} GROUP BY day",
452
+ parameters={"end": end},
453
+ ).result_rows
454
+ )
455
+ window = [end - timedelta(days=i) for i in range(WEEK - 1, -1, -1)]
456
+ days = {day: int(counted.get(day, 0)) for day in window if day >= first}
457
+ typical: dict[date, int | None] = {}
458
+ for day in days:
459
+ past = [day - timedelta(weeks=w) for w in range(1, PAST_WEEKS + 1)]
460
+ past = [p for p in past if p >= first]
461
+ typical[day] = int(statistics.median(counted.get(p, 0) for p in past)) if past else None
462
+ return Volume(package, table, first, end, days, typical)
463
+
464
+
465
+ def rows_per_day(client: Client, config: Config) -> list[Finding]:
466
+ """A day without rows in the last 7 days that are over of a table where that is a gap."""
467
+ findings: list[Finding] = []
468
+ at = now()
469
+ for package in config.packages():
470
+ declared = _deployment(config, package)
471
+ last = _last_day(config, declared, at)
472
+ for counted in declared.daily:
473
+ if not counted.gap:
474
+ continue
475
+ table, column = counted.table, counted.date
476
+ try:
477
+ volume = _volume(client, config.clickhouse.database, package, table, column, last)
478
+ except ClickHouseError as exc:
479
+ findings.append(
480
+ Finding(
481
+ "rows per day",
482
+ table,
483
+ "error",
484
+ _first_line(exc),
485
+ "rows every day",
486
+ f"{table} ({package}) could not be counted; build it with `dactopus run`.",
487
+ )
488
+ )
489
+ continue
490
+ if volume.first is None:
491
+ findings.append(
492
+ Finding(
493
+ "rows per day",
494
+ table,
495
+ "warn",
496
+ "no rows",
497
+ "rows every day",
498
+ f"{table} ({package}) has no rows yet.",
499
+ )
500
+ )
501
+ continue
502
+ if volume.latest is None:
503
+ findings.append(
504
+ Finding(
505
+ "rows per day",
506
+ table,
507
+ "pass",
508
+ "rows only for today",
509
+ "rows every day",
510
+ f"{table} ({package}) has rows only for today so far; a day is "
511
+ "counted once it is over.",
512
+ )
513
+ )
514
+ continue
515
+ gaps = volume.gaps
516
+ first_day = min(volume.days)
517
+ if gaps:
518
+ others = [rows for rows in volume.days.values() if rows]
519
+ sentence = f"{table} ({package}) has no rows for {_days(gaps)}"
520
+ if others:
521
+ spread = (
522
+ f"{min(others):,}"
523
+ if min(others) == max(others)
524
+ else f"{min(others):,} to {max(others):,}"
525
+ )
526
+ sentence += f"; the other days of that week have {spread}"
527
+ findings.append(
528
+ Finding(
529
+ "rows per day",
530
+ table,
531
+ "warn",
532
+ f"no rows on {', '.join(day.isoformat() for day in gaps)}",
533
+ "rows every day",
534
+ f"{sentence}.",
535
+ )
536
+ )
537
+ elif volume.quiet:
538
+ quiet = volume.quiet
539
+ findings.append(
540
+ Finding(
541
+ "rows per day",
542
+ table,
543
+ "pass",
544
+ f"no rows on {', '.join(day.isoformat() for day in quiet)}, as before",
545
+ "rows every day the same weekday had rows",
546
+ f"{table} ({package}) has no rows for {_days(quiet)}, as on the same "
547
+ f"weekday in the {PAST_WEEKS} weeks before.",
548
+ )
549
+ )
550
+ else:
551
+ findings.append(
552
+ Finding(
553
+ "rows per day",
554
+ table,
555
+ "pass",
556
+ f"rows on every day from {first_day} to {volume.latest}",
557
+ "rows every day",
558
+ f"{table} ({package}) has rows on every day from {first_day} "
559
+ f"to {volume.latest}.",
560
+ )
561
+ )
562
+ return findings
563
+
564
+
565
+ def _days(days: list[date]) -> str:
566
+ if len(days) == 1:
567
+ return days[0].isoformat()
568
+ return ", ".join(day.isoformat() for day in days[:-1]) + f" and {days[-1].isoformat()}"
569
+
570
+
571
+ # Time zone: the daily tables with a time column, counted for rows whose
572
+ # date is not the day of their time in store.timezone. A package with a
573
+ # time zone variable is built in that zone, so such a row means tables
574
+ # built in another one. A package without one has the days of its source
575
+ # (GA4: the property's zone), counted over the days rows_per_day reads.
576
+
577
+
578
+ def time_zone(client: Client, config: Config) -> list[Finding]:
579
+ """Whether the days of each daily table are those of store.timezone."""
580
+ findings: list[Finding] = []
581
+ zone = config.store.timezone
582
+ expected = f"every row on its day in {zone}"
583
+ for package in config.packages():
584
+ declared = _deployment(config, package)
585
+ built_in_zone = "time_zone" in declared.vars
586
+ for counted in declared.daily:
587
+ if counted.time is None:
588
+ continue
589
+ table = counted.table
590
+ days = None if built_in_zone else WEEK * (PAST_WEEKS + 1)
591
+ try:
592
+ off = misdated(client, config.clickhouse.database, counted, zone, days)
593
+ except ClickHouseError as exc:
594
+ findings.append(
595
+ Finding(
596
+ "time zone",
597
+ table,
598
+ "error",
599
+ _first_line(exc),
600
+ expected,
601
+ f"The days of {table} ({package}) could not be counted; check that "
602
+ "the ClickHouse user can read it and run again.",
603
+ )
604
+ )
605
+ continue
606
+ if not off.of:
607
+ findings.append(
608
+ Finding(
609
+ "time zone",
610
+ table,
611
+ "skip",
612
+ "no rows",
613
+ expected,
614
+ f"{table} ({package}) has no rows to check the days of.",
615
+ )
616
+ )
617
+ continue
618
+ observed = f"{off.rows:,} of {off.of:,} rows on another day"
619
+ if days is not None:
620
+ observed += f", from {off.first} to {off.last}"
621
+ if not off.rows:
622
+ findings.append(
623
+ Finding(
624
+ "time zone",
625
+ table,
626
+ "pass",
627
+ observed,
628
+ expected,
629
+ f"Every row of {table} ({package}) is on its day in {zone}.",
630
+ )
631
+ )
632
+ elif built_in_zone:
633
+ findings.append(
634
+ Finding(
635
+ "time zone",
636
+ table,
637
+ "fail",
638
+ observed,
639
+ expected,
640
+ f"{_n(off.rows, 'row')} of {table} ({package}) "
641
+ f"{_verb(off.rows, 'is', 'are')} on another day than in {zone}: the "
642
+ "table was built in another time zone. Run `dactopus run "
643
+ f"--full-refresh {package}` once to rebuild it.",
644
+ )
645
+ )
646
+ else:
647
+ findings.append(
648
+ Finding(
649
+ "time zone",
650
+ table,
651
+ "warn",
652
+ observed,
653
+ expected,
654
+ f"{_share(off.rows, off.of)} of the rows of {table} ({package}) from "
655
+ f"{off.first} to {off.last} are on another day in {zone}: the source "
656
+ 'counts its days in another time zone, so "today" in an agent\'s '
657
+ "question and days compared with other packages are off by the "
658
+ "difference between the zones.",
659
+ )
660
+ )
661
+ return findings
662
+
663
+
664
+ def _share(rows: int, of: int) -> str:
665
+ """The share of rows in of, never 0.0% when a row is counted nor 100.0% when one is not."""
666
+ share = rows / of
667
+ if share < 0.001:
668
+ return "under 0.1%"
669
+ if share > 0.999 and rows < of:
670
+ return "over 99.9%"
671
+ return f"{share:.1%}"