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/__init__.py +1 -0
- dactopus/build.py +221 -0
- dactopus/checks.py +671 -0
- dactopus/cli.py +182 -0
- dactopus/clickhouse.py +32 -0
- dactopus/config.py +353 -0
- dactopus/ga4.py +253 -0
- dactopus/library.py +210 -0
- dactopus/report.py +129 -0
- dactopus/serve.py +59 -0
- dactopus/shopify.py +475 -0
- dactopus-0.2.0.dist-info/METADATA +286 -0
- dactopus-0.2.0.dist-info/RECORD +16 -0
- dactopus-0.2.0.dist-info/WHEEL +4 -0
- dactopus-0.2.0.dist-info/entry_points.txt +8 -0
- dactopus-0.2.0.dist-info/licenses/LICENSE +202 -0
dactopus/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runner of Dactopus."""
|
dactopus/build.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Building the canonical tables: dbt build of each package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import date
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from clickhouse_connect.driver.client import Client
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .library import Daily, deployment
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BuildError(Exception):
|
|
22
|
+
"""dbt could not build a package."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _dbt() -> str:
|
|
26
|
+
# The dbt installed with the runner, next to its Python.
|
|
27
|
+
local = Path(sys.executable).parent / "dbt"
|
|
28
|
+
if local.exists():
|
|
29
|
+
return str(local)
|
|
30
|
+
found = shutil.which("dbt")
|
|
31
|
+
if found is None:
|
|
32
|
+
raise BuildError("build: dbt is not installed next to the runner; reinstall dactopus.")
|
|
33
|
+
return found
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_profile(config: Config, project: Path) -> Path:
|
|
37
|
+
"""Write the dbt profile the package names, in the workdir; return its directory.
|
|
38
|
+
|
|
39
|
+
The password stays out of the file: dbt reads it from the environment.
|
|
40
|
+
"""
|
|
41
|
+
name = yaml.safe_load((project / "dbt_project.yml").read_text())["profile"]
|
|
42
|
+
ch = config.clickhouse
|
|
43
|
+
profile = {
|
|
44
|
+
name: {
|
|
45
|
+
"target": "default",
|
|
46
|
+
"outputs": {
|
|
47
|
+
"default": {
|
|
48
|
+
"type": "clickhouse",
|
|
49
|
+
"host": ch.host,
|
|
50
|
+
"port": ch.port,
|
|
51
|
+
"secure": ch.secure,
|
|
52
|
+
"user": ch.user,
|
|
53
|
+
# dbt scrubs DBT_ENV_SECRET_ values from its logs.
|
|
54
|
+
"password": "{{ env_var('DBT_ENV_SECRET_CLICKHOUSE_PASSWORD', '') }}",
|
|
55
|
+
# The database the canonical tables are written to.
|
|
56
|
+
"schema": ch.database,
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
config.workdir.mkdir(exist_ok=True)
|
|
62
|
+
(config.workdir / "profiles.yml").write_text(yaml.safe_dump(profile, sort_keys=False))
|
|
63
|
+
return config.workdir
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def target(config: Config, package: str) -> Path:
|
|
67
|
+
"""Where dbt writes the package's run_results.json, manifest.json and sources.json."""
|
|
68
|
+
return config.workdir / package / "target"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def command(
|
|
72
|
+
config: Config, library: Path, package: str, *dbt_args: str
|
|
73
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
74
|
+
"""The dbt command line of a package (build by default) and its environment."""
|
|
75
|
+
project = library / "sources" / package
|
|
76
|
+
profiles = write_profile(config, project)
|
|
77
|
+
output = config.workdir / package
|
|
78
|
+
variables = dict(config.packages()[package].vars)
|
|
79
|
+
declared = deployment(config.models, library, package).vars
|
|
80
|
+
variables[declared["input_database"]] = config.clickhouse.input_database
|
|
81
|
+
if "time_zone" in declared:
|
|
82
|
+
variables[declared["time_zone"]] = config.store.timezone
|
|
83
|
+
argv = [
|
|
84
|
+
_dbt(),
|
|
85
|
+
*(dbt_args or ("build",)),
|
|
86
|
+
"--project-dir",
|
|
87
|
+
str(project),
|
|
88
|
+
"--profiles-dir",
|
|
89
|
+
str(profiles),
|
|
90
|
+
"--target-path",
|
|
91
|
+
str(output / "target"),
|
|
92
|
+
"--log-path",
|
|
93
|
+
str(output / "logs"),
|
|
94
|
+
"--vars",
|
|
95
|
+
json.dumps(variables),
|
|
96
|
+
]
|
|
97
|
+
env = {
|
|
98
|
+
**os.environ,
|
|
99
|
+
"DBT_ENV_SECRET_CLICKHOUSE_PASSWORD": config.clickhouse.password,
|
|
100
|
+
# No events about the client's runs go to dbt Labs, and dbt writes no
|
|
101
|
+
# .user.yml next to the profile.
|
|
102
|
+
"DBT_SEND_ANONYMOUS_USAGE_STATS": "false",
|
|
103
|
+
}
|
|
104
|
+
return argv, env
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run(config: Config, library: Path, package: str, *dbt_args: str, writes: str) -> None:
|
|
108
|
+
"""Run dbt on a package; raise when it could not run at all.
|
|
109
|
+
|
|
110
|
+
dbt exits with 1 when a model, test or source fails and with 2 when
|
|
111
|
+
it could not run (dbt 1.11.16, 2026-10-22); the file it writes tells the two
|
|
112
|
+
apart. Failures are read from that file by the checks.
|
|
113
|
+
"""
|
|
114
|
+
argv, env = command(config, library, package, *dbt_args)
|
|
115
|
+
written = target(config, package) / writes
|
|
116
|
+
written.unlink(missing_ok=True)
|
|
117
|
+
print(f"build: dbt {' '.join(dbt_args)} of {package}", file=sys.stderr)
|
|
118
|
+
# dbt prints its own progress; the password is only in the environment.
|
|
119
|
+
result = subprocess.run(argv, env=env, cwd=config.workdir)
|
|
120
|
+
if result.returncode not in (0, 1) or not written.exists():
|
|
121
|
+
raise BuildError(
|
|
122
|
+
f"build: dbt {' '.join(dbt_args)} of {package} could not run (exit "
|
|
123
|
+
f"{result.returncode}); see its output above and "
|
|
124
|
+
f"{config.workdir / package / 'logs' / 'dbt.log'}."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build(config: Config, library: Path, package: str, full_refresh: bool = False) -> None:
|
|
129
|
+
"""dbt build: the models, their tests and unit tests.
|
|
130
|
+
|
|
131
|
+
A full refresh rebuilds the canonical tables from the input tables as
|
|
132
|
+
they are; dbt-clickhouse builds each new table next to the old one and
|
|
133
|
+
swaps them when it is complete.
|
|
134
|
+
"""
|
|
135
|
+
args = ("build", "--full-refresh") if full_refresh else ("build",)
|
|
136
|
+
_run(config, library, package, *args, writes="run_results.json")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test(config: Config, library: Path, package: str) -> None:
|
|
140
|
+
"""dbt test: the tests and unit tests over the tables as they are."""
|
|
141
|
+
_run(config, library, package, "test", writes="run_results.json")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def freshness(config: Config, library: Path, package: str) -> None:
|
|
145
|
+
"""dbt source freshness: the age of the input against the package's thresholds."""
|
|
146
|
+
_run(config, library, package, "source", "freshness", writes="sources.json")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class Misdated:
|
|
151
|
+
"""Rows of a daily table whose date is not the day of their time in a zone."""
|
|
152
|
+
|
|
153
|
+
rows: int
|
|
154
|
+
# Of all the rows counted, from first to last by date; 0 and None for a
|
|
155
|
+
# missing or empty table.
|
|
156
|
+
of: int
|
|
157
|
+
first: date | None = None
|
|
158
|
+
last: date | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def misdated(
|
|
162
|
+
client: Client, database: str, counted: Daily, zone: str, days: int | None = None
|
|
163
|
+
) -> Misdated:
|
|
164
|
+
"""Count the rows of a daily table that fall on another day in zone.
|
|
165
|
+
|
|
166
|
+
A table built in the zone has none. ``days`` limits the count to the
|
|
167
|
+
days up to its latest date, for a table too large to read whole. A
|
|
168
|
+
table or database that does not exist yet counts as empty.
|
|
169
|
+
"""
|
|
170
|
+
exists = client.query(
|
|
171
|
+
"SELECT count() FROM system.tables WHERE database = {db:String} AND name = {t:String}",
|
|
172
|
+
parameters={"db": database, "t": counted.table},
|
|
173
|
+
).result_rows[0][0]
|
|
174
|
+
if not exists:
|
|
175
|
+
return Misdated(0, 0)
|
|
176
|
+
qualified = f"`{database}`.`{counted.table}`"
|
|
177
|
+
day, time = f"`{counted.date}`", f"`{counted.time}`"
|
|
178
|
+
where = ""
|
|
179
|
+
if days is not None:
|
|
180
|
+
where = f" WHERE {day} > (SELECT max({day}) FROM {qualified}) - INTERVAL {days} DAY"
|
|
181
|
+
rows, of, first, last = client.query(
|
|
182
|
+
f"SELECT countIf({day} != toDate({time}, {{zone:String}})), count(), "
|
|
183
|
+
f"min({day}), max({day}) FROM {qualified}{where}",
|
|
184
|
+
parameters={"zone": zone},
|
|
185
|
+
).result_rows[0]
|
|
186
|
+
if not of:
|
|
187
|
+
return Misdated(0, 0)
|
|
188
|
+
return Misdated(int(rows), int(of), first, last)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def check_zone(client: Client, config: Config, library: Path, refresh: set[str]) -> None:
|
|
192
|
+
"""Refuse to build over tables built in a zone other than store.timezone.
|
|
193
|
+
|
|
194
|
+
The incremental models of a package with a time zone variable rebuild
|
|
195
|
+
only the rows that changed, so a build after the zone changed would
|
|
196
|
+
leave the tables with days of both zones. A package rebuilt with
|
|
197
|
+
--full-refresh is not counted.
|
|
198
|
+
"""
|
|
199
|
+
zone = config.store.timezone
|
|
200
|
+
found: dict[str, list[str]] = {}
|
|
201
|
+
for package in config.packages():
|
|
202
|
+
declared = deployment(config.models, library, package)
|
|
203
|
+
if "time_zone" not in declared.vars or package in refresh:
|
|
204
|
+
continue
|
|
205
|
+
for counted in declared.daily:
|
|
206
|
+
if counted.time is None:
|
|
207
|
+
continue
|
|
208
|
+
off = misdated(client, config.clickhouse.database, counted, zone)
|
|
209
|
+
if off.rows:
|
|
210
|
+
found.setdefault(package, []).append(
|
|
211
|
+
f"{off.rows:,} of {off.of:,} rows of {counted.table} ({package})"
|
|
212
|
+
)
|
|
213
|
+
if found:
|
|
214
|
+
packages = " ".join(found)
|
|
215
|
+
tables = ", ".join(t for ts in found.values() for t in ts)
|
|
216
|
+
raise BuildError(
|
|
217
|
+
f"build: the tables of {', '.join(found)} were built in a time zone other than "
|
|
218
|
+
f"store.timezone ({zone}): {tables} fall on another day in it. Run "
|
|
219
|
+
f"`dactopus run --full-refresh {packages}` once to rebuild them; until then "
|
|
220
|
+
"the agent keeps answering from the current tables."
|
|
221
|
+
)
|