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 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())
@@ -0,0 +1,5 @@
1
+ """Generated API layer. Do not hand edit; see tools/generate.py."""
2
+
3
+ from telmai._generated.api import GeneratedApi
4
+
5
+ __all__ = ["GeneratedApi"]