tiltmeter 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.
- tiltmeter/__init__.py +5 -0
- tiltmeter/__main__.py +6 -0
- tiltmeter/cli.py +331 -0
- tiltmeter/config.py +246 -0
- tiltmeter/console.py +151 -0
- tiltmeter/core/__init__.py +5 -0
- tiltmeter/core/analyze.py +117 -0
- tiltmeter/core/classify.py +75 -0
- tiltmeter/core/diff.py +220 -0
- tiltmeter/core/fetch.py +253 -0
- tiltmeter/core/models.py +271 -0
- tiltmeter/core/paths.py +204 -0
- tiltmeter/core/shape.py +343 -0
- tiltmeter/core/store.py +395 -0
- tiltmeter/core/timeutil.py +39 -0
- tiltmeter/errors.py +27 -0
- tiltmeter/graph/__init__.py +10 -0
- tiltmeter/graph/builder.py +38 -0
- tiltmeter/graph/nodes.py +166 -0
- tiltmeter/graph/state.py +64 -0
- tiltmeter/llm/__init__.py +1 -0
- tiltmeter/llm/explainer.py +179 -0
- tiltmeter/llm/prompts.py +77 -0
- tiltmeter/mcp_server/__init__.py +1 -0
- tiltmeter/mcp_server/server.py +170 -0
- tiltmeter/notify/__init__.py +37 -0
- tiltmeter/notify/base.py +66 -0
- tiltmeter/notify/slack.py +72 -0
- tiltmeter/notify/webhook.py +48 -0
- tiltmeter/py.typed +0 -0
- tiltmeter/service.py +276 -0
- tiltmeter-0.1.0.dist-info/METADATA +240 -0
- tiltmeter-0.1.0.dist-info/RECORD +36 -0
- tiltmeter-0.1.0.dist-info/WHEEL +4 -0
- tiltmeter-0.1.0.dist-info/entry_points.txt +2 -0
- tiltmeter-0.1.0.dist-info/licenses/LICENSE +21 -0
tiltmeter/__init__.py
ADDED
tiltmeter/__main__.py
ADDED
tiltmeter/cli.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""The ``tiltmeter`` command.
|
|
2
|
+
|
|
3
|
+
Exit codes, so CI can gate on drift:
|
|
4
|
+
|
|
5
|
+
* 0: no drift (info-only changes and new baselines included)
|
|
6
|
+
* 1: warning-level drift
|
|
7
|
+
* 2: breaking drift
|
|
8
|
+
* 3: a check could not run (network error, bad status, invalid JSON) and nothing broke
|
|
9
|
+
* 4: usage or configuration error
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Coroutine, Iterator, Sequence
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING, Annotated, Any, Final, NoReturn
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
from tiltmeter import __version__
|
|
26
|
+
from tiltmeter.config import DEFAULT_CONFIG_FILE, EXAMPLE_CONFIG
|
|
27
|
+
from tiltmeter.core.models import CheckResult, Severity, max_severity
|
|
28
|
+
from tiltmeter.errors import TiltmeterError
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from tiltmeter.service import DriftService
|
|
32
|
+
|
|
33
|
+
EXIT_OK: Final = 0
|
|
34
|
+
EXIT_WARNING: Final = 1
|
|
35
|
+
EXIT_BREAKING: Final = 2
|
|
36
|
+
EXIT_CHECK_FAILED: Final = 3
|
|
37
|
+
EXIT_USAGE: Final = 4
|
|
38
|
+
|
|
39
|
+
STATE_IGNORE_LINE: Final = ".tiltmeter/"
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
name="tiltmeter",
|
|
43
|
+
help="Catch API schema drift before your AI agents do.",
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
add_completion=False,
|
|
46
|
+
pretty_exceptions_enable=False,
|
|
47
|
+
)
|
|
48
|
+
out = Console(highlight=False)
|
|
49
|
+
err = Console(stderr=True, highlight=False)
|
|
50
|
+
|
|
51
|
+
ConfigOption = Annotated[
|
|
52
|
+
Path,
|
|
53
|
+
typer.Option(
|
|
54
|
+
"--config", "-c", envvar="TILTMETER_CONFIG", help="Path to the configuration file."
|
|
55
|
+
),
|
|
56
|
+
]
|
|
57
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Print JSON instead of tables.")]
|
|
58
|
+
EndpointsOption = Annotated[
|
|
59
|
+
list[str] | None,
|
|
60
|
+
typer.Option("--endpoint", "-e", help="Only these endpoint ids (repeatable)."),
|
|
61
|
+
]
|
|
62
|
+
NoLLMOption = Annotated[bool, typer.Option("--no-llm", help="Skip Claude explanations.")]
|
|
63
|
+
NoNotifyOption = Annotated[bool, typer.Option("--no-notify", help="Do not send notifications.")]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def exit_code_for(results: Sequence[CheckResult]) -> int:
|
|
67
|
+
"""Breaking beats a failed check, which beats a warning."""
|
|
68
|
+
worst = max_severity(result.max_severity for result in results)
|
|
69
|
+
if worst is Severity.BREAKING:
|
|
70
|
+
return EXIT_BREAKING
|
|
71
|
+
if any(result.error for result in results):
|
|
72
|
+
return EXIT_CHECK_FAILED
|
|
73
|
+
if worst is Severity.WARNING:
|
|
74
|
+
return EXIT_WARNING
|
|
75
|
+
return EXIT_OK
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fail(message: str, code: int = EXIT_USAGE) -> NoReturn:
|
|
79
|
+
err.print(f"[bold red]error:[/] {message}", markup=True, highlight=False)
|
|
80
|
+
raise typer.Exit(code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _service(config: Path) -> DriftService:
|
|
84
|
+
from tiltmeter.service import DriftService # deferred: keeps --help fast
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
return DriftService.from_config_file(config)
|
|
88
|
+
except TiltmeterError as exc:
|
|
89
|
+
_fail(str(exc))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextmanager
|
|
93
|
+
def _opened(config: Path) -> Iterator[DriftService]:
|
|
94
|
+
service = _service(config)
|
|
95
|
+
try:
|
|
96
|
+
yield service
|
|
97
|
+
finally:
|
|
98
|
+
asyncio.run(service.aclose())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run[T](coroutine: Coroutine[Any, Any, T]) -> T:
|
|
102
|
+
try:
|
|
103
|
+
return asyncio.run(coroutine)
|
|
104
|
+
except TiltmeterError as exc:
|
|
105
|
+
_fail(str(exc))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _pause(seconds: float) -> None:
|
|
109
|
+
await asyncio.sleep(seconds)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _print_json(data: Any) -> None:
|
|
113
|
+
sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit(results: Sequence[CheckResult], as_json: bool) -> None:
|
|
117
|
+
if as_json:
|
|
118
|
+
_print_json([result.model_dump(mode="json") for result in results])
|
|
119
|
+
else:
|
|
120
|
+
from tiltmeter.console import render_results
|
|
121
|
+
|
|
122
|
+
render_results(out, results)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _version(value: bool) -> None:
|
|
126
|
+
if value:
|
|
127
|
+
out.print(f"tiltmeter {__version__}")
|
|
128
|
+
raise typer.Exit()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.callback()
|
|
132
|
+
def _root(
|
|
133
|
+
version: Annotated[
|
|
134
|
+
bool,
|
|
135
|
+
typer.Option("--version", callback=_version, is_eager=True, help="Show the version."),
|
|
136
|
+
] = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""Catch API schema drift before your AI agents do.
|
|
139
|
+
|
|
140
|
+
Tiltmeter calls the APIs you declare in tiltmeter.yaml, learns the shape of
|
|
141
|
+
their JSON responses, and tells you (or your agents) when it changes.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def init(
|
|
147
|
+
path: Annotated[Path, typer.Argument(help="Where to write the config.")] = Path(
|
|
148
|
+
DEFAULT_CONFIG_FILE
|
|
149
|
+
),
|
|
150
|
+
force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Write a starter tiltmeter.yaml."""
|
|
153
|
+
if path.exists() and not force:
|
|
154
|
+
_fail(f"{path} already exists (use --force to overwrite it)")
|
|
155
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
157
|
+
out.print(f"[green]✓[/] wrote {path}")
|
|
158
|
+
gitignore = path.parent / ".gitignore"
|
|
159
|
+
if gitignore.exists() or (path.parent / ".git").exists():
|
|
160
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
161
|
+
if STATE_IGNORE_LINE not in existing.splitlines():
|
|
162
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
163
|
+
with gitignore.open("a", encoding="utf-8") as handle:
|
|
164
|
+
handle.write(f"{prefix}{STATE_IGNORE_LINE}\n")
|
|
165
|
+
out.print(f"[green]✓[/] added {STATE_IGNORE_LINE} to {gitignore}")
|
|
166
|
+
out.print("Next: edit the endpoints, then run [bold]tiltmeter check[/].")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command()
|
|
170
|
+
def check(
|
|
171
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
172
|
+
endpoint: EndpointsOption = None,
|
|
173
|
+
as_json: JsonOption = False,
|
|
174
|
+
no_llm: NoLLMOption = False,
|
|
175
|
+
no_notify: NoNotifyOption = False,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Check endpoints once. The exit code tells CI how bad the drift is."""
|
|
178
|
+
|
|
179
|
+
async def run() -> list[CheckResult]:
|
|
180
|
+
async with _service(config) as service:
|
|
181
|
+
return await service.check(endpoint, explain=not no_llm, notify=not no_notify)
|
|
182
|
+
|
|
183
|
+
results = _run(run())
|
|
184
|
+
_emit(results, as_json)
|
|
185
|
+
raise typer.Exit(exit_code_for(results))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command()
|
|
189
|
+
def watch(
|
|
190
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
191
|
+
interval: Annotated[
|
|
192
|
+
float, typer.Option("--interval", "-i", min=5, help="Seconds between rounds.")
|
|
193
|
+
] = 300.0,
|
|
194
|
+
endpoint: EndpointsOption = None,
|
|
195
|
+
as_json: Annotated[
|
|
196
|
+
bool, typer.Option("--json", help="Print one JSON line per check (JSON Lines).")
|
|
197
|
+
] = False,
|
|
198
|
+
no_llm: NoLLMOption = False,
|
|
199
|
+
no_notify: NoNotifyOption = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Check endpoints forever, every --interval seconds. Stop with Ctrl+C."""
|
|
202
|
+
|
|
203
|
+
async def run() -> None:
|
|
204
|
+
async with _service(config) as service:
|
|
205
|
+
while True:
|
|
206
|
+
results = await service.check(endpoint, explain=not no_llm, notify=not no_notify)
|
|
207
|
+
if as_json:
|
|
208
|
+
for result in results:
|
|
209
|
+
sys.stdout.write(result.model_dump_json() + "\n")
|
|
210
|
+
sys.stdout.flush()
|
|
211
|
+
else:
|
|
212
|
+
from tiltmeter.console import render_results
|
|
213
|
+
|
|
214
|
+
render_results(out, results)
|
|
215
|
+
await _pause(interval)
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
_run(run())
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
err.print("stopped")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def history(
|
|
225
|
+
endpoint_id: Annotated[str | None, typer.Argument(help="Only this endpoint.")] = None,
|
|
226
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
227
|
+
limit: Annotated[int, typer.Option("--limit", "-n", min=1, max=10_000)] = 20,
|
|
228
|
+
as_json: JsonOption = False,
|
|
229
|
+
) -> None:
|
|
230
|
+
"""List recent checks, newest first."""
|
|
231
|
+
with _opened(config) as service:
|
|
232
|
+
checks = service.history(endpoint_id, limit=limit)
|
|
233
|
+
if as_json:
|
|
234
|
+
_print_json([check.model_dump(mode="json") for check in checks])
|
|
235
|
+
return
|
|
236
|
+
if not checks:
|
|
237
|
+
out.print("No checks yet. Run [bold]tiltmeter check[/].")
|
|
238
|
+
return
|
|
239
|
+
from tiltmeter.console import history_table
|
|
240
|
+
|
|
241
|
+
out.print(history_table(checks))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@app.command()
|
|
245
|
+
def show(
|
|
246
|
+
check_id: Annotated[int, typer.Argument(help="Check id, as listed by `history`.")],
|
|
247
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
248
|
+
as_json: JsonOption = False,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Show one check: its changes and explanation."""
|
|
251
|
+
with _opened(config) as service:
|
|
252
|
+
result = service.get_check(check_id)
|
|
253
|
+
if result is None:
|
|
254
|
+
_fail(f"no check with id {check_id}")
|
|
255
|
+
if as_json:
|
|
256
|
+
_print_json(result.model_dump(mode="json"))
|
|
257
|
+
else:
|
|
258
|
+
from tiltmeter.console import render_result
|
|
259
|
+
|
|
260
|
+
render_result(out, result, show_id=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.command()
|
|
264
|
+
def schema(
|
|
265
|
+
endpoint_id: Annotated[str, typer.Argument(help="Endpoint id from the config.")],
|
|
266
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Print the JSON Schema Tiltmeter has learned for an endpoint."""
|
|
269
|
+
with _opened(config) as service:
|
|
270
|
+
try:
|
|
271
|
+
report = service.schema(endpoint_id)
|
|
272
|
+
except TiltmeterError as exc:
|
|
273
|
+
_fail(str(exc))
|
|
274
|
+
if report.json_schema is None:
|
|
275
|
+
_fail(f"no baseline for {endpoint_id} yet; run `tiltmeter check` first", EXIT_CHECK_FAILED)
|
|
276
|
+
_print_json(report.json_schema)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command()
|
|
280
|
+
def explain(
|
|
281
|
+
check_id: Annotated[int, typer.Argument(help="Check id, as listed by `history`.")],
|
|
282
|
+
config: ConfigOption = Path(DEFAULT_CONFIG_FILE),
|
|
283
|
+
as_json: JsonOption = False,
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Ask Claude to explain the changes a past check found (needs ANTHROPIC_API_KEY)."""
|
|
286
|
+
|
|
287
|
+
async def run() -> CheckResult:
|
|
288
|
+
async with _service(config) as service:
|
|
289
|
+
return await service.explain_check(check_id)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
result = _run(run())
|
|
293
|
+
except typer.Exit:
|
|
294
|
+
raise
|
|
295
|
+
except Exception as exc: # API errors from the model provider: no traceback for users
|
|
296
|
+
_fail(f"explanation failed: {exc}", EXIT_CHECK_FAILED)
|
|
297
|
+
if as_json:
|
|
298
|
+
_print_json(result.model_dump(mode="json"))
|
|
299
|
+
else:
|
|
300
|
+
from tiltmeter.console import render_result
|
|
301
|
+
|
|
302
|
+
render_result(out, result, show_id=True)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@app.command()
|
|
306
|
+
def serve(config: ConfigOption = Path(DEFAULT_CONFIG_FILE)) -> None:
|
|
307
|
+
"""Run the MCP server on stdio, for Claude Code, Cursor and other MCP clients."""
|
|
308
|
+
from tiltmeter.mcp_server.server import run_stdio
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
run_stdio(config)
|
|
312
|
+
except TiltmeterError as exc:
|
|
313
|
+
_fail(str(exc))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
317
|
+
"""Entry point. Maps usage errors to exit code 4 so they never look like drift."""
|
|
318
|
+
try:
|
|
319
|
+
result = app(args=list(argv) if argv is not None else None, standalone_mode=False)
|
|
320
|
+
except typer.TyperException as exc:
|
|
321
|
+
show_error = getattr(exc, "show", None)
|
|
322
|
+
if callable(show_error):
|
|
323
|
+
show_error()
|
|
324
|
+
else:
|
|
325
|
+
err.print(str(exc))
|
|
326
|
+
no_arguments = type(exc).__name__ == "NoArgsIsHelpError"
|
|
327
|
+
sys.exit(EXIT_OK if no_arguments else EXIT_USAGE)
|
|
328
|
+
except typer.Abort:
|
|
329
|
+
err.print("aborted")
|
|
330
|
+
sys.exit(130)
|
|
331
|
+
sys.exit(result if isinstance(result, int) else EXIT_OK)
|
tiltmeter/config.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""The ``tiltmeter.yaml`` file: which endpoints to watch and how.
|
|
2
|
+
|
|
3
|
+
Values may contain ``${VAR}`` or ``${VAR:-default}`` placeholders. They are kept
|
|
4
|
+
as written in memory and only expanded when a request or notification is sent,
|
|
5
|
+
so secrets never sit in the config objects that get logged or traced.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, Literal
|
|
13
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
from tiltmeter.core.models import ChangeKind, Severity
|
|
19
|
+
from tiltmeter.core.paths import PathPattern, PathSyntaxError
|
|
20
|
+
from tiltmeter.errors import ConfigError, UnknownEndpointError
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONFIG_FILE = "tiltmeter.yaml"
|
|
23
|
+
DEFAULT_MODEL = "claude-haiku-5-5"
|
|
24
|
+
|
|
25
|
+
type HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
|
|
26
|
+
type Effort = Literal["low", "medium", "high", "xhigh", "max"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Model(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _stringify(value: Any) -> Any:
|
|
34
|
+
"""YAML turns ``limit: 3`` into an int; headers and params must be strings."""
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
return {str(k): ("" if v is None else str(v)) for k, v in value.items()}
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EndpointConfig(_Model):
|
|
41
|
+
"""One HTTP endpoint to watch."""
|
|
42
|
+
|
|
43
|
+
id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,63}$")
|
|
44
|
+
url: str
|
|
45
|
+
method: HttpMethod = "GET"
|
|
46
|
+
description: str | None = None
|
|
47
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
48
|
+
params: dict[str, str] = Field(default_factory=dict)
|
|
49
|
+
body: Any = None
|
|
50
|
+
timeout: float = Field(default=10.0, gt=0, le=300)
|
|
51
|
+
retries: int = Field(default=2, ge=0, le=10)
|
|
52
|
+
sample_size: int = Field(default=1, ge=1, le=20)
|
|
53
|
+
max_body_bytes: int = Field(default=5_000_000, ge=1_000, le=200_000_000)
|
|
54
|
+
ignore_paths: list[str] = Field(default_factory=list)
|
|
55
|
+
strict: bool = False
|
|
56
|
+
baseline_window: int = Field(default=20, ge=1, le=500)
|
|
57
|
+
latency_factor: float | None = Field(default=3.0, gt=1)
|
|
58
|
+
severity_overrides: dict[ChangeKind, Severity] = Field(default_factory=dict)
|
|
59
|
+
store_samples: bool = False
|
|
60
|
+
|
|
61
|
+
@field_validator("method", mode="before")
|
|
62
|
+
@classmethod
|
|
63
|
+
def _upper_method(cls, value: Any) -> Any:
|
|
64
|
+
return value.upper() if isinstance(value, str) else value
|
|
65
|
+
|
|
66
|
+
@field_validator("headers", "params", mode="before")
|
|
67
|
+
@classmethod
|
|
68
|
+
def _string_values(cls, value: Any) -> Any:
|
|
69
|
+
return _stringify(value)
|
|
70
|
+
|
|
71
|
+
@field_validator("url")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _check_url(cls, value: str) -> str:
|
|
74
|
+
if not value.startswith(("http://", "https://", "${")):
|
|
75
|
+
raise ValueError("must start with http:// or https:// (or a ${VAR} placeholder)")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
@field_validator("ignore_paths")
|
|
79
|
+
@classmethod
|
|
80
|
+
def _check_patterns(cls, value: list[str]) -> list[str]:
|
|
81
|
+
for pattern in value:
|
|
82
|
+
try:
|
|
83
|
+
PathPattern(pattern)
|
|
84
|
+
except PathSyntaxError as exc:
|
|
85
|
+
raise ValueError(str(exc)) from exc
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
def display_url(self) -> str:
|
|
89
|
+
"""The URL as written, without query string or fragment (they often hold keys)."""
|
|
90
|
+
parts = urlsplit(self.url)
|
|
91
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SlackNotifierConfig(_Model):
|
|
95
|
+
"""Post to a Slack incoming webhook."""
|
|
96
|
+
|
|
97
|
+
type: Literal["slack"]
|
|
98
|
+
webhook_url: str
|
|
99
|
+
min_severity: Severity = Severity.WARNING
|
|
100
|
+
on_error: bool = False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class WebhookNotifierConfig(_Model):
|
|
104
|
+
"""POST the check result as JSON to any URL."""
|
|
105
|
+
|
|
106
|
+
type: Literal["webhook"]
|
|
107
|
+
url: str
|
|
108
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
109
|
+
min_severity: Severity = Severity.WARNING
|
|
110
|
+
on_error: bool = False
|
|
111
|
+
|
|
112
|
+
@field_validator("headers", mode="before")
|
|
113
|
+
@classmethod
|
|
114
|
+
def _string_values(cls, value: Any) -> Any:
|
|
115
|
+
return _stringify(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
type NotifierConfig = Annotated[
|
|
119
|
+
SlackNotifierConfig | WebhookNotifierConfig, Field(discriminator="type")
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class LLMConfig(_Model):
|
|
124
|
+
"""Optional drift explanations written by Claude."""
|
|
125
|
+
|
|
126
|
+
enabled: bool = True
|
|
127
|
+
model: str = DEFAULT_MODEL
|
|
128
|
+
effort: Effort | None = "low"
|
|
129
|
+
max_tokens: int = Field(default=2048, ge=256, le=32_000)
|
|
130
|
+
timeout: float = Field(default=60.0, gt=0, le=600)
|
|
131
|
+
min_severity: Severity = Severity.WARNING
|
|
132
|
+
include_samples: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Config(_Model):
|
|
136
|
+
"""The whole configuration file."""
|
|
137
|
+
|
|
138
|
+
version: Literal[1] = 1
|
|
139
|
+
state_dir: str = ".tiltmeter"
|
|
140
|
+
concurrency: int = Field(default=5, ge=1, le=64)
|
|
141
|
+
keep_checks: int = Field(default=500, ge=10, le=100_000)
|
|
142
|
+
severity_overrides: dict[ChangeKind, Severity] = Field(default_factory=dict)
|
|
143
|
+
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
144
|
+
notify: list[NotifierConfig] = Field(default_factory=list)
|
|
145
|
+
endpoints: list[EndpointConfig] = Field(min_length=1)
|
|
146
|
+
|
|
147
|
+
@model_validator(mode="after")
|
|
148
|
+
def _unique_ids(self) -> Config:
|
|
149
|
+
seen: set[str] = set()
|
|
150
|
+
for endpoint in self.endpoints:
|
|
151
|
+
if endpoint.id in seen:
|
|
152
|
+
raise ValueError(f"duplicate endpoint id {endpoint.id!r}")
|
|
153
|
+
seen.add(endpoint.id)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def endpoint_ids(self) -> list[str]:
|
|
158
|
+
return [endpoint.id for endpoint in self.endpoints]
|
|
159
|
+
|
|
160
|
+
def endpoint(self, endpoint_id: str) -> EndpointConfig:
|
|
161
|
+
for endpoint in self.endpoints:
|
|
162
|
+
if endpoint.id == endpoint_id:
|
|
163
|
+
return endpoint
|
|
164
|
+
raise UnknownEndpointError(endpoint_id, self.endpoint_ids)
|
|
165
|
+
|
|
166
|
+
def select(self, endpoint_ids: Sequence[str] | None = None) -> list[EndpointConfig]:
|
|
167
|
+
"""The endpoints with these ids, in the given order; all of them when empty."""
|
|
168
|
+
if not endpoint_ids:
|
|
169
|
+
return list(self.endpoints)
|
|
170
|
+
return [self.endpoint(endpoint_id) for endpoint_id in dict.fromkeys(endpoint_ids)]
|
|
171
|
+
|
|
172
|
+
def overrides_for(self, endpoint: EndpointConfig) -> dict[ChangeKind, Severity]:
|
|
173
|
+
"""Global severity overrides, with the endpoint's own taking precedence."""
|
|
174
|
+
return {**self.severity_overrides, **endpoint.severity_overrides}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_config(path: Path) -> Config:
|
|
178
|
+
"""Read and validate a config file, with readable errors."""
|
|
179
|
+
if not path.is_file():
|
|
180
|
+
raise ConfigError(f"config file not found: {path} (run `tiltmeter init` to create one)")
|
|
181
|
+
try:
|
|
182
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
183
|
+
except yaml.YAMLError as exc:
|
|
184
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
185
|
+
if not isinstance(data, dict):
|
|
186
|
+
raise ConfigError(f"{path}: expected a mapping with an `endpoints` list at the top level")
|
|
187
|
+
try:
|
|
188
|
+
return Config.model_validate(data)
|
|
189
|
+
except ValidationError as exc:
|
|
190
|
+
raise ConfigError(f"{path}: invalid configuration\n{format_validation_error(exc)}") from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def format_validation_error(exc: ValidationError) -> str:
|
|
194
|
+
lines = []
|
|
195
|
+
for error in exc.errors():
|
|
196
|
+
location = ".".join(str(part) for part in error["loc"]) or "(root)"
|
|
197
|
+
lines.append(f" - {location}: {error['msg']}")
|
|
198
|
+
return "\n".join(lines)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def resolve_state_dir(config: Config, config_path: Path) -> Path:
|
|
202
|
+
"""``state_dir`` resolved relative to the config file's folder."""
|
|
203
|
+
state_dir = Path(config.state_dir).expanduser()
|
|
204
|
+
if not state_dir.is_absolute():
|
|
205
|
+
state_dir = config_path.resolve().parent / state_dir
|
|
206
|
+
return state_dir
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
EXAMPLE_CONFIG = """\
|
|
210
|
+
# tiltmeter configuration. Docs: see README.md
|
|
211
|
+
version: 1
|
|
212
|
+
|
|
213
|
+
endpoints:
|
|
214
|
+
# A public API, no credentials needed.
|
|
215
|
+
- id: github_repo
|
|
216
|
+
description: Repository metadata from the GitHub REST API
|
|
217
|
+
url: https://api.github.com/repos/langchain-ai/langchain
|
|
218
|
+
headers:
|
|
219
|
+
Accept: application/vnd.github+json
|
|
220
|
+
|
|
221
|
+
# Secrets come from environment variables and are expanded at request time.
|
|
222
|
+
# - id: stripe_customers
|
|
223
|
+
# url: https://api.stripe.com/v1/customers
|
|
224
|
+
# params:
|
|
225
|
+
# limit: 10
|
|
226
|
+
# headers:
|
|
227
|
+
# Authorization: Bearer ${STRIPE_API_KEY}
|
|
228
|
+
# ignore_paths:
|
|
229
|
+
# - $.data[*].metadata # free-form, changes all the time
|
|
230
|
+
# severity_overrides:
|
|
231
|
+
# field_now_optional: warning
|
|
232
|
+
|
|
233
|
+
# Explanations written by Claude when drift is detected (needs ANTHROPIC_API_KEY).
|
|
234
|
+
llm:
|
|
235
|
+
enabled: true
|
|
236
|
+
model: claude-haiku-5-5
|
|
237
|
+
|
|
238
|
+
# Where to send alerts.
|
|
239
|
+
notify: []
|
|
240
|
+
# - type: slack
|
|
241
|
+
# webhook_url: ${SLACK_WEBHOOK_URL}
|
|
242
|
+
# min_severity: warning
|
|
243
|
+
# - type: webhook
|
|
244
|
+
# url: https://example.com/hooks/tiltmeter
|
|
245
|
+
# min_severity: breaking
|
|
246
|
+
"""
|