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 ADDED
@@ -0,0 +1,5 @@
1
+ """Tiltmeter: an early-warning system for the APIs your AI agents depend on."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
tiltmeter/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m tiltmeter``."""
2
+
3
+ from tiltmeter.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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
+ """