telmai 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.
- telmai/__init__.py +51 -0
- telmai/__main__.py +188 -0
- telmai/_generated/__init__.py +5 -0
- telmai/_generated/api.py +3431 -0
- telmai/client.py +407 -0
- telmai/errors.py +87 -0
- telmai/features/__init__.py +12 -0
- telmai/features/gate.py +446 -0
- telmai/features/waiters.py +124 -0
- telmai/models.py +385 -0
- telmai/py.typed +0 -0
- telmai/resources/__init__.py +32 -0
- telmai/resources/assets.py +171 -0
- telmai/resources/binning.py +94 -0
- telmai/resources/connections.py +115 -0
- telmai/resources/monitors.py +214 -0
- telmai/resources/results.py +130 -0
- telmai/resources/scans.py +188 -0
- telmai/transport.py +347 -0
- telmai-0.2.0.dist-info/METADATA +295 -0
- telmai-0.2.0.dist-info/RECORD +25 -0
- telmai-0.2.0.dist-info/WHEEL +4 -0
- telmai-0.2.0.dist-info/entry_points.txt +2 -0
- telmai-0.2.0.dist-info/licenses/LICENSE +202 -0
- telmai-0.2.0.dist-info/licenses/NOTICE +7 -0
telmai/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Telmai Python SDK.
|
|
2
|
+
|
|
3
|
+
from telmai import Telmai, Severity
|
|
4
|
+
|
|
5
|
+
tm = Telmai.from_env()
|
|
6
|
+
tm.circuit_breaker("a1b2c3d4e5f6")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from telmai.client import Telmai
|
|
10
|
+
from telmai.errors import (
|
|
11
|
+
CircuitBreakerTripped,
|
|
12
|
+
TelmaiAPIError,
|
|
13
|
+
TelmaiAssetError,
|
|
14
|
+
TelmaiAuthError,
|
|
15
|
+
TelmaiConfigError,
|
|
16
|
+
TelmaiError,
|
|
17
|
+
TelmaiTimeout,
|
|
18
|
+
)
|
|
19
|
+
from telmai.models import (
|
|
20
|
+
Alert,
|
|
21
|
+
AlertPriority,
|
|
22
|
+
AlertType,
|
|
23
|
+
BinningLocation,
|
|
24
|
+
JobStatus,
|
|
25
|
+
QuarantineResult,
|
|
26
|
+
ScanStatus,
|
|
27
|
+
ScanVerdict,
|
|
28
|
+
Severity,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.2.0"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Telmai",
|
|
35
|
+
"Severity",
|
|
36
|
+
"JobStatus",
|
|
37
|
+
"ScanStatus",
|
|
38
|
+
"Alert",
|
|
39
|
+
"AlertPriority",
|
|
40
|
+
"AlertType",
|
|
41
|
+
"ScanVerdict",
|
|
42
|
+
"QuarantineResult",
|
|
43
|
+
"BinningLocation",
|
|
44
|
+
"TelmaiError",
|
|
45
|
+
"TelmaiConfigError",
|
|
46
|
+
"TelmaiAuthError",
|
|
47
|
+
"TelmaiAPIError",
|
|
48
|
+
"TelmaiTimeout",
|
|
49
|
+
"TelmaiAssetError",
|
|
50
|
+
"CircuitBreakerTripped",
|
|
51
|
+
]
|
telmai/__main__.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Command line entry point, for orchestrators that shell out.
|
|
2
|
+
|
|
3
|
+
python -m telmai gate --assets a1b2c3d4e5f6,f6e5d4c3b2a10 --min-severity HIGH
|
|
4
|
+
|
|
5
|
+
Assets are addressed by id. `python -m telmai resolve gold.orders` prints the id
|
|
6
|
+
for a table name, so a job definition can hold the id rather than a name that
|
|
7
|
+
someone may rename later.
|
|
8
|
+
|
|
9
|
+
Exit codes are the contract. Anything that can run a process and read an exit
|
|
10
|
+
status can use the gate, no Python required:
|
|
11
|
+
|
|
12
|
+
0 clean, every asset passed
|
|
13
|
+
2 blocked, at least one asset failed a quality check
|
|
14
|
+
1 error, could not determine an answer (auth, config, bad asset id)
|
|
15
|
+
|
|
16
|
+
Note that 1 and 2 are deliberately different. A tool that cannot tell whether
|
|
17
|
+
the data is good is not the same as one reporting that the data is bad, and an
|
|
18
|
+
orchestrator often wants to alert differently on each. Both are non-zero, so
|
|
19
|
+
the default behaviour is still to stop.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import sys
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
|
|
30
|
+
from telmai.client import Telmai
|
|
31
|
+
from telmai.errors import CircuitBreakerTripped, TelmaiError
|
|
32
|
+
from telmai.features.gate import DEFAULT_MAX_CONCURRENCY
|
|
33
|
+
from telmai.models import ScanVerdict, Severity
|
|
34
|
+
|
|
35
|
+
EXIT_CLEAN = 0
|
|
36
|
+
EXIT_ERROR = 1
|
|
37
|
+
EXIT_BLOCKED = 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
prog="python -m telmai",
|
|
43
|
+
description="Gate a pipeline on Telmai data quality.",
|
|
44
|
+
)
|
|
45
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
gate = sub.add_parser("gate", help="scan assets and exit non-zero if not clean")
|
|
48
|
+
gate.add_argument(
|
|
49
|
+
"--assets",
|
|
50
|
+
required=True,
|
|
51
|
+
help="comma separated asset ids. Use the resolve command to look one up from a table name.",
|
|
52
|
+
)
|
|
53
|
+
gate.add_argument(
|
|
54
|
+
"--mode",
|
|
55
|
+
default="block",
|
|
56
|
+
choices=["block", "notify"],
|
|
57
|
+
help="block exits 2 on bad data, notify always exits 0 (default: block)",
|
|
58
|
+
)
|
|
59
|
+
gate.add_argument("--min-severity", default="HIGH", help="HIGH, MEDIUM, or LOW")
|
|
60
|
+
gate.add_argument("--tags", default="", help="comma separated monitor tags that also block")
|
|
61
|
+
gate.add_argument("--timeout-s", type=int, default=3600)
|
|
62
|
+
gate.add_argument("--max-concurrency", type=int, default=DEFAULT_MAX_CONCURRENCY)
|
|
63
|
+
gate.add_argument("--json", action="store_true", help="emit machine readable output")
|
|
64
|
+
gate.add_argument("--verbose", "-v", action="store_true")
|
|
65
|
+
|
|
66
|
+
resolve = sub.add_parser("resolve", help="print the asset id for a table name, then exit")
|
|
67
|
+
resolve.add_argument("name", help="the table name as onboarded in Telmai")
|
|
68
|
+
resolve.add_argument("--verbose", "-v", action="store_true")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _render(verdicts: Sequence[ScanVerdict], as_json: bool) -> None:
|
|
73
|
+
if as_json:
|
|
74
|
+
print(
|
|
75
|
+
json.dumps(
|
|
76
|
+
{
|
|
77
|
+
"passed": all(v.passed for v in verdicts),
|
|
78
|
+
"assets": [
|
|
79
|
+
{
|
|
80
|
+
"asset_id": v.asset_id,
|
|
81
|
+
"asset_name": v.asset_name,
|
|
82
|
+
"passed": v.passed,
|
|
83
|
+
"scan_status": v.scan_status,
|
|
84
|
+
"job_id": v.job_id,
|
|
85
|
+
"blocking": [
|
|
86
|
+
{"policy": a.policy_name, "detail": a.description}
|
|
87
|
+
for a in v.blocking_alerts
|
|
88
|
+
],
|
|
89
|
+
}
|
|
90
|
+
for v in verdicts
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
indent=2,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
for v in verdicts:
|
|
99
|
+
print(v)
|
|
100
|
+
for a in v.blocking_alerts:
|
|
101
|
+
print(f" BLOCKING {a.policy_name} {a.description}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str] | None = None) -> int:
|
|
105
|
+
args = _build_parser().parse_args(argv)
|
|
106
|
+
logging.basicConfig(
|
|
107
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
108
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if args.command == "resolve":
|
|
112
|
+
# Prints the bare id and nothing else, so it composes:
|
|
113
|
+
# ID=$(python -m telmai resolve gold.orders)
|
|
114
|
+
try:
|
|
115
|
+
print(Telmai.from_env().resolve(args.name))
|
|
116
|
+
except TelmaiError as exc:
|
|
117
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
118
|
+
return EXIT_ERROR
|
|
119
|
+
return EXIT_CLEAN
|
|
120
|
+
|
|
121
|
+
assets = [a.strip() for a in args.assets.split(",") if a.strip()]
|
|
122
|
+
tags = {t.strip() for t in args.tags.split(",") if t.strip()} or None
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
tm = Telmai.from_env()
|
|
126
|
+
severity = Severity.parse(args.min_severity)
|
|
127
|
+
except (TelmaiError, ValueError) as exc:
|
|
128
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
129
|
+
return EXIT_ERROR
|
|
130
|
+
|
|
131
|
+
# Arguments passed explicitly rather than via **kwargs: a dict splat
|
|
132
|
+
# defeats overload resolution, so the return type would collapse back to a
|
|
133
|
+
# union and the isinstance dance would be needed again.
|
|
134
|
+
try:
|
|
135
|
+
if args.mode == "notify":
|
|
136
|
+
verdicts = tm.live_pass_through(
|
|
137
|
+
assets,
|
|
138
|
+
min_severity=severity,
|
|
139
|
+
tags=tags,
|
|
140
|
+
timeout_s=args.timeout_s,
|
|
141
|
+
max_concurrency=args.max_concurrency,
|
|
142
|
+
)
|
|
143
|
+
_render(verdicts, args.json)
|
|
144
|
+
return EXIT_CLEAN # notify never blocks, by definition
|
|
145
|
+
|
|
146
|
+
verdicts = tm.circuit_breaker(
|
|
147
|
+
assets,
|
|
148
|
+
min_severity=severity,
|
|
149
|
+
tags=tags,
|
|
150
|
+
timeout_s=args.timeout_s,
|
|
151
|
+
max_concurrency=args.max_concurrency,
|
|
152
|
+
)
|
|
153
|
+
_render(verdicts, args.json)
|
|
154
|
+
return EXIT_CLEAN
|
|
155
|
+
|
|
156
|
+
except CircuitBreakerTripped as tripped:
|
|
157
|
+
_render(tripped.verdicts, args.json)
|
|
158
|
+
if not args.json:
|
|
159
|
+
print(f"\n{tripped}", file=sys.stderr)
|
|
160
|
+
|
|
161
|
+
# Exit 2 means "the data is bad". Exit 1 means "I could not tell".
|
|
162
|
+
# A trigger failure, a broken upstream connection, or a timeout all
|
|
163
|
+
# block the pipeline, correctly, but none of them is a quality verdict,
|
|
164
|
+
# so reporting them as 2 would send someone hunting for a data problem
|
|
165
|
+
# that does not exist. Only a real blocking alert earns a 2.
|
|
166
|
+
# `blocked_by_quality` rather than `blocking_alerts`: a PROCESS_FAILURE
|
|
167
|
+
# alert blocks, correctly, but it means the scan could not read the
|
|
168
|
+
# table, not that the data is wrong. Confirmed live: PROCESS_FAILURE
|
|
169
|
+
# alerts on a real tenant all carried P1.
|
|
170
|
+
if any(v.blocked_by_quality for v in tripped.verdicts):
|
|
171
|
+
return EXIT_BLOCKED
|
|
172
|
+
for v in tripped.failed:
|
|
173
|
+
for a in v.process_failures:
|
|
174
|
+
print(f"process failure on {v.label}: {a.description}", file=sys.stderr)
|
|
175
|
+
print(
|
|
176
|
+
f"could not determine quality for {v.label}: {v.detail or v.scan_status}",
|
|
177
|
+
file=sys.stderr,
|
|
178
|
+
)
|
|
179
|
+
return EXIT_ERROR
|
|
180
|
+
|
|
181
|
+
except TelmaiError as exc:
|
|
182
|
+
# Could not reach a verdict. Distinct from a verdict of "bad".
|
|
183
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
184
|
+
return EXIT_ERROR
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|