watchdiff-core 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/.gitignore +2 -1
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/PKG-INFO +185 -2
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/README.md +183 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/pyproject.toml +2 -2
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/__init__.py +37 -0
- watchdiff_core-0.2.1/watchdiff/ai_summarizer/__init__.py +224 -0
- watchdiff_core-0.2.1/watchdiff/cert_fetcher/__init__.py +51 -0
- watchdiff_core-0.2.1/watchdiff/cert_models.py +109 -0
- watchdiff_core-0.2.1/watchdiff/cert_scheduler/__init__.py +257 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/core.py +186 -7
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_models.py +6 -2
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_scheduler/__init__.py +86 -21
- watchdiff_core-0.2.1/watchdiff/file_fetcher/__init__.py +26 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/models.py +12 -2
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/scheduler/scheduler.py +129 -26
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/status_server/server.py +1 -1
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/.github/workflows/ci.yml +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/.github/workflows/release-testpypi.yml +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/.github/workflows/release.yml +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/.python-version +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/LICENSE +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/main.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/tests/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/tests/test_db.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/tests/test_watchdiff.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/uv.lock +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/cleaner/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/cleaner/cleaner.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/cli/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/cli/main.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_diff/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_fetcher/mysql.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_fetcher/postgres.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/db_fetcher/sqlite.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/diff/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/diff/engine.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/exporter/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/exporter/exporter.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/fetcher/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/fetcher/browser.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/fetcher/fetcher.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/notifier/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/notifier/notifier.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/parser/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/parser/parser.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/scheduler/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/status_server/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/store/__init__.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/store/sqlite_store.py +0 -0
- {watchdiff_core-0.2.0 → watchdiff_core-0.2.1}/watchdiff/store/store.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: watchdiff-core
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Lightweight web change monitoring library - clean diffs,
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Lightweight web change monitoring library - clean diffs, AI summaries, file/API/SSL/DB monitoring, structured alerts.
|
|
5
5
|
Author: WatchDiff Contributors
|
|
6
6
|
License: BSD-2-Clause
|
|
7
7
|
License-File: LICENSE
|
|
@@ -1049,6 +1049,125 @@ watchdiff status
|
|
|
1049
1049
|
watchdiff status --config watchdiff.config.json
|
|
1050
1050
|
```
|
|
1051
1051
|
|
|
1052
|
+
## AI summaries
|
|
1053
|
+
|
|
1054
|
+
Add a natural-language summary to every change alert. WatchDiff sends the diff to your AI provider of choice and attaches the result to `DiffReport.ai_summary`.
|
|
1055
|
+
|
|
1056
|
+
### Auto-discovery (recommended)
|
|
1057
|
+
|
|
1058
|
+
Set one of the following environment variables - WatchDiff picks it up automatically:
|
|
1059
|
+
|
|
1060
|
+
```bash
|
|
1061
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
1062
|
+
export OPENAI_API_KEY="sk-..."
|
|
1063
|
+
export GEMINI_API_KEY="AIza..."
|
|
1064
|
+
```
|
|
1065
|
+
|
|
1066
|
+
```python
|
|
1067
|
+
wd.watch("https://example.com/prices", target=".price",
|
|
1068
|
+
ai_summary=True, # provider auto-detected from env
|
|
1069
|
+
on_change=lambda r: print(r.ai_summary))
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
Priority order when multiple keys are set: `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GEMINI_API_KEY` / `GOOGLE_AI_API_KEY`.
|
|
1073
|
+
|
|
1074
|
+
### Explicit provider
|
|
1075
|
+
|
|
1076
|
+
```python
|
|
1077
|
+
from watchdiff import AiProvider
|
|
1078
|
+
|
|
1079
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1080
|
+
ai_provider=AiProvider(type="gemini", api_key="...", model="gemini-3.1-flash-lite"))
|
|
1081
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1082
|
+
ai_provider=AiProvider(type="anthropic", api_key="...", model="claude-haiku-4-5-20251001"))
|
|
1083
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1084
|
+
ai_provider=AiProvider(type="openai", api_key="...", model="gpt-4o-mini"))
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
### OpenAI-compatible providers (Ollama, Mistral, Groq, ...)
|
|
1088
|
+
|
|
1089
|
+
```python
|
|
1090
|
+
# Local Ollama
|
|
1091
|
+
AiProvider(type="openai", base_url="http://localhost:11434/v1", model="llama3")
|
|
1092
|
+
|
|
1093
|
+
# Mistral
|
|
1094
|
+
AiProvider(type="openai", api_key="...", base_url="https://api.mistral.ai/v1", model="mistral-small")
|
|
1095
|
+
|
|
1096
|
+
# Groq
|
|
1097
|
+
AiProvider(type="openai", api_key="...", base_url="https://api.groq.com/openai/v1", model="llama3-8b-8192")
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
### Custom function (any provider, any library)
|
|
1101
|
+
|
|
1102
|
+
```python
|
|
1103
|
+
import anthropic
|
|
1104
|
+
|
|
1105
|
+
client = anthropic.Anthropic()
|
|
1106
|
+
|
|
1107
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1108
|
+
ai_provider=AiProvider(
|
|
1109
|
+
type="custom",
|
|
1110
|
+
call_ai=lambda prompt: client.messages.create(
|
|
1111
|
+
model="claude-opus-4-7",
|
|
1112
|
+
max_tokens=200,
|
|
1113
|
+
messages=[{"role": "user", "content": prompt}],
|
|
1114
|
+
).content[0].text,
|
|
1115
|
+
))
|
|
1116
|
+
```
|
|
1117
|
+
|
|
1118
|
+
### Custom prompt
|
|
1119
|
+
|
|
1120
|
+
Override the default summary template with `ai_prompt` - pass a static string or a callable that receives the `DiffReport` and returns a prompt string.
|
|
1121
|
+
|
|
1122
|
+
```python
|
|
1123
|
+
# Static prompt - replaces the full instruction sent to the AI
|
|
1124
|
+
wd.watch_file("/tmp/prices.txt", ai_summary=True,
|
|
1125
|
+
ai_prompt="Summarize this change in French in one sentence.")
|
|
1126
|
+
|
|
1127
|
+
# Dynamic prompt - build the prompt from the diff report
|
|
1128
|
+
wd.watch("https://example.com/stock", ai_summary=True,
|
|
1129
|
+
ai_prompt=lambda r: (
|
|
1130
|
+
f"You are monitoring {r.url}. "
|
|
1131
|
+
f"Explain in plain English what changed: "
|
|
1132
|
+
+ ", ".join(c.after or c.before or "" for c in r.changes)
|
|
1133
|
+
))
|
|
1134
|
+
```
|
|
1135
|
+
|
|
1136
|
+
The prompt is sent as-is to the provider - no variable substitution happens automatically. Use the callable form to interpolate any data from the `DiffReport`.
|
|
1137
|
+
|
|
1138
|
+
### Built-in providers
|
|
1139
|
+
|
|
1140
|
+
| Provider | `type` | Default model |
|
|
1141
|
+
|---|---|---|
|
|
1142
|
+
| Google Gemini | `"gemini"` | `gemini-3.1-flash-lite` |
|
|
1143
|
+
| Anthropic Claude | `"anthropic"` | `claude-haiku-4-5-20251001` |
|
|
1144
|
+
| OpenAI / compatible | `"openai"` | `gpt-4o-mini` |
|
|
1145
|
+
| Any custom function | `"custom"` | - |
|
|
1146
|
+
|
|
1147
|
+
### Error handling
|
|
1148
|
+
|
|
1149
|
+
AI failures are non-fatal - the watcher keeps running and fires its normal alerts.
|
|
1150
|
+
|
|
1151
|
+
| Error kind | What triggers it | Behaviour |
|
|
1152
|
+
|---|---|---|
|
|
1153
|
+
| `invalid_key` | 401 / 403 | AI disabled for this watcher for the rest of the session |
|
|
1154
|
+
| `quota_exceeded` | 429 rate limit | Skips AI this check, retries next interval |
|
|
1155
|
+
| `model_error` | 400 / unknown model | AI disabled - check your `model` value |
|
|
1156
|
+
| `network_error` | DNS / timeout | Skips this check, retries next interval |
|
|
1157
|
+
|
|
1158
|
+
```python
|
|
1159
|
+
from watchdiff import AiError
|
|
1160
|
+
|
|
1161
|
+
try:
|
|
1162
|
+
from watchdiff.ai_summarizer import generate_ai_summary
|
|
1163
|
+
summary = generate_ai_summary(report, provider)
|
|
1164
|
+
except AiError as e:
|
|
1165
|
+
print(e.kind) # AiErrorKind.QUOTA_EXCEEDED
|
|
1166
|
+
print(e.is_retryable) # True - retry next check
|
|
1167
|
+
print(e.is_permanent) # False - don't disable
|
|
1168
|
+
print(e.status_code) # 429
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1052
1171
|
## API reference
|
|
1053
1172
|
|
|
1054
1173
|
### `WatchDiff`
|
|
@@ -1103,6 +1222,12 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
|
|
|
1103
1222
|
| `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
|
|
1104
1223
|
| `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
|
|
1105
1224
|
| `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
|
|
1225
|
+
| `alert_if` | `Callable[[DiffReport], bool] \| None` | `None` | Custom gate - only alert when this function returns `True` |
|
|
1226
|
+
| `expected_status` | `int \| None` | `None` | Fire `on_error` when actual HTTP status differs from this value |
|
|
1227
|
+
| `track_response_time` | `bool` | `False` | Record response time in milliseconds in `DiffReport.response_time_ms` |
|
|
1228
|
+
| `ai_summary` | `bool` | `False` | Generate an AI natural-language summary of detected changes |
|
|
1229
|
+
| `ai_provider` | `AiProvider \| None` | `None` | AI provider to use. Auto-detected from env vars when `None` |
|
|
1230
|
+
| `ai_prompt` | `str \| Callable[[DiffReport], str] \| None` | `None` | Custom prompt sent to the AI instead of the default template |
|
|
1106
1231
|
|
|
1107
1232
|
```python
|
|
1108
1233
|
# Chainable
|
|
@@ -1151,6 +1276,64 @@ wd.watch_db("sqlite:///app.db", "orders",
|
|
|
1151
1276
|
wd.start()
|
|
1152
1277
|
```
|
|
1153
1278
|
|
|
1279
|
+
#### `.watch_file(path, *, ...)`
|
|
1280
|
+
|
|
1281
|
+
Watch a local file for changes. Accepts all the same options as `.watch()` (except browser-related ones). The path is converted to a `file://` URL internally. Content is compared as raw text, bypassing the HTML cleaner.
|
|
1282
|
+
|
|
1283
|
+
```python
|
|
1284
|
+
wd.watch_file("/etc/nginx/nginx.conf", interval=30, diff_mode="line",
|
|
1285
|
+
on_change=lambda r: print("Config changed:", r.changes))
|
|
1286
|
+
|
|
1287
|
+
wd.watch_file("/var/log/app.log", interval=5,
|
|
1288
|
+
alert_if=lambda r: any("ERROR" in (c.after or "") for c in r.changes))
|
|
1289
|
+
|
|
1290
|
+
wd.watch_file("/tmp/prices.txt", interval=2, ai_summary=True,
|
|
1291
|
+
on_change=lambda r: print(r.ai_summary))
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
#### `.watch_api(url, *, ...)`
|
|
1295
|
+
|
|
1296
|
+
Convenience wrapper over `.watch()` pre-configured for JSON API monitoring: sets `diff_mode="json"`, `track_response_time=True`, and `expected_status=200` by default.
|
|
1297
|
+
|
|
1298
|
+
```python
|
|
1299
|
+
wd.watch_api("https://api.example.com/prices",
|
|
1300
|
+
interval=60,
|
|
1301
|
+
on_change=lambda r: print(f"API changed, responded in {r.response_time_ms:.0f}ms"))
|
|
1302
|
+
|
|
1303
|
+
# Alert when response time exceeds 500ms
|
|
1304
|
+
wd.watch_api("https://api.example.com/health",
|
|
1305
|
+
interval=30,
|
|
1306
|
+
alert_if=lambda r: (r.response_time_ms or 0) > 500)
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
#### `.watch_cert(host, *, port=443, warning_days=30, ...)`
|
|
1310
|
+
|
|
1311
|
+
Monitor an SSL/TLS certificate for expiry and fingerprint changes.
|
|
1312
|
+
|
|
1313
|
+
| Parameter | Type | Default | Description |
|
|
1314
|
+
|---|---|---|---|
|
|
1315
|
+
| `hostname` | `str` | — | Hostname to connect to |
|
|
1316
|
+
| `port` | `int` | `443` | TLS port |
|
|
1317
|
+
| `interval` | `int` | `86400` | Seconds between checks |
|
|
1318
|
+
| `label` | `str \| None` | `hostname:port` | Human-readable name |
|
|
1319
|
+
| `warn_days_before_expiry` | `int` | `30` | Days before expiry to start firing `on_expiry` |
|
|
1320
|
+
| `alert_on_change` | `bool` | `True` | Fire `on_change` when cert fingerprint changes |
|
|
1321
|
+
| `alert_on_expiry` | `bool` | `True` | Fire `on_expiry` when cert is nearing expiry |
|
|
1322
|
+
| `on_expiry` | `Callable \| None` | `None` | Called with `CertExpiryInfo` when expiry is approaching |
|
|
1323
|
+
| `on_change` | `Callable \| None` | `None` | Called with `CertChangeInfo` when fingerprint changes |
|
|
1324
|
+
| `on_error` | `Callable \| None` | `None` | Called with `(exc, config)` on fetch error |
|
|
1325
|
+
|
|
1326
|
+
```python
|
|
1327
|
+
wd.watch_cert("api.example.com",
|
|
1328
|
+
warn_days_before_expiry=14,
|
|
1329
|
+
on_expiry=lambda i: print(f"Cert expires in {i.days_until_expiry} days"),
|
|
1330
|
+
on_change=lambda i: print("Cert fingerprint changed!", i.current_fingerprint))
|
|
1331
|
+
|
|
1332
|
+
statuses = wd.get_cert_statuses() # list[CertWatcherStatus]
|
|
1333
|
+
for s in statuses:
|
|
1334
|
+
print(s.hostname, s.last_check_at, s.days_until_expiry, s.is_expiring_soon)
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1154
1337
|
#### `.on_change(callback)`
|
|
1155
1338
|
|
|
1156
1339
|
Register a global callback called whenever any watched URL changes:
|
|
@@ -1001,6 +1001,125 @@ watchdiff status
|
|
|
1001
1001
|
watchdiff status --config watchdiff.config.json
|
|
1002
1002
|
```
|
|
1003
1003
|
|
|
1004
|
+
## AI summaries
|
|
1005
|
+
|
|
1006
|
+
Add a natural-language summary to every change alert. WatchDiff sends the diff to your AI provider of choice and attaches the result to `DiffReport.ai_summary`.
|
|
1007
|
+
|
|
1008
|
+
### Auto-discovery (recommended)
|
|
1009
|
+
|
|
1010
|
+
Set one of the following environment variables - WatchDiff picks it up automatically:
|
|
1011
|
+
|
|
1012
|
+
```bash
|
|
1013
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
1014
|
+
export OPENAI_API_KEY="sk-..."
|
|
1015
|
+
export GEMINI_API_KEY="AIza..."
|
|
1016
|
+
```
|
|
1017
|
+
|
|
1018
|
+
```python
|
|
1019
|
+
wd.watch("https://example.com/prices", target=".price",
|
|
1020
|
+
ai_summary=True, # provider auto-detected from env
|
|
1021
|
+
on_change=lambda r: print(r.ai_summary))
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Priority order when multiple keys are set: `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` → `GEMINI_API_KEY` / `GOOGLE_AI_API_KEY`.
|
|
1025
|
+
|
|
1026
|
+
### Explicit provider
|
|
1027
|
+
|
|
1028
|
+
```python
|
|
1029
|
+
from watchdiff import AiProvider
|
|
1030
|
+
|
|
1031
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1032
|
+
ai_provider=AiProvider(type="gemini", api_key="...", model="gemini-3.1-flash-lite"))
|
|
1033
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1034
|
+
ai_provider=AiProvider(type="anthropic", api_key="...", model="claude-haiku-4-5-20251001"))
|
|
1035
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1036
|
+
ai_provider=AiProvider(type="openai", api_key="...", model="gpt-4o-mini"))
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
### OpenAI-compatible providers (Ollama, Mistral, Groq, ...)
|
|
1040
|
+
|
|
1041
|
+
```python
|
|
1042
|
+
# Local Ollama
|
|
1043
|
+
AiProvider(type="openai", base_url="http://localhost:11434/v1", model="llama3")
|
|
1044
|
+
|
|
1045
|
+
# Mistral
|
|
1046
|
+
AiProvider(type="openai", api_key="...", base_url="https://api.mistral.ai/v1", model="mistral-small")
|
|
1047
|
+
|
|
1048
|
+
# Groq
|
|
1049
|
+
AiProvider(type="openai", api_key="...", base_url="https://api.groq.com/openai/v1", model="llama3-8b-8192")
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
### Custom function (any provider, any library)
|
|
1053
|
+
|
|
1054
|
+
```python
|
|
1055
|
+
import anthropic
|
|
1056
|
+
|
|
1057
|
+
client = anthropic.Anthropic()
|
|
1058
|
+
|
|
1059
|
+
wd.watch("https://example.com", ai_summary=True,
|
|
1060
|
+
ai_provider=AiProvider(
|
|
1061
|
+
type="custom",
|
|
1062
|
+
call_ai=lambda prompt: client.messages.create(
|
|
1063
|
+
model="claude-opus-4-7",
|
|
1064
|
+
max_tokens=200,
|
|
1065
|
+
messages=[{"role": "user", "content": prompt}],
|
|
1066
|
+
).content[0].text,
|
|
1067
|
+
))
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
### Custom prompt
|
|
1071
|
+
|
|
1072
|
+
Override the default summary template with `ai_prompt` - pass a static string or a callable that receives the `DiffReport` and returns a prompt string.
|
|
1073
|
+
|
|
1074
|
+
```python
|
|
1075
|
+
# Static prompt - replaces the full instruction sent to the AI
|
|
1076
|
+
wd.watch_file("/tmp/prices.txt", ai_summary=True,
|
|
1077
|
+
ai_prompt="Summarize this change in French in one sentence.")
|
|
1078
|
+
|
|
1079
|
+
# Dynamic prompt - build the prompt from the diff report
|
|
1080
|
+
wd.watch("https://example.com/stock", ai_summary=True,
|
|
1081
|
+
ai_prompt=lambda r: (
|
|
1082
|
+
f"You are monitoring {r.url}. "
|
|
1083
|
+
f"Explain in plain English what changed: "
|
|
1084
|
+
+ ", ".join(c.after or c.before or "" for c in r.changes)
|
|
1085
|
+
))
|
|
1086
|
+
```
|
|
1087
|
+
|
|
1088
|
+
The prompt is sent as-is to the provider - no variable substitution happens automatically. Use the callable form to interpolate any data from the `DiffReport`.
|
|
1089
|
+
|
|
1090
|
+
### Built-in providers
|
|
1091
|
+
|
|
1092
|
+
| Provider | `type` | Default model |
|
|
1093
|
+
|---|---|---|
|
|
1094
|
+
| Google Gemini | `"gemini"` | `gemini-3.1-flash-lite` |
|
|
1095
|
+
| Anthropic Claude | `"anthropic"` | `claude-haiku-4-5-20251001` |
|
|
1096
|
+
| OpenAI / compatible | `"openai"` | `gpt-4o-mini` |
|
|
1097
|
+
| Any custom function | `"custom"` | - |
|
|
1098
|
+
|
|
1099
|
+
### Error handling
|
|
1100
|
+
|
|
1101
|
+
AI failures are non-fatal - the watcher keeps running and fires its normal alerts.
|
|
1102
|
+
|
|
1103
|
+
| Error kind | What triggers it | Behaviour |
|
|
1104
|
+
|---|---|---|
|
|
1105
|
+
| `invalid_key` | 401 / 403 | AI disabled for this watcher for the rest of the session |
|
|
1106
|
+
| `quota_exceeded` | 429 rate limit | Skips AI this check, retries next interval |
|
|
1107
|
+
| `model_error` | 400 / unknown model | AI disabled - check your `model` value |
|
|
1108
|
+
| `network_error` | DNS / timeout | Skips this check, retries next interval |
|
|
1109
|
+
|
|
1110
|
+
```python
|
|
1111
|
+
from watchdiff import AiError
|
|
1112
|
+
|
|
1113
|
+
try:
|
|
1114
|
+
from watchdiff.ai_summarizer import generate_ai_summary
|
|
1115
|
+
summary = generate_ai_summary(report, provider)
|
|
1116
|
+
except AiError as e:
|
|
1117
|
+
print(e.kind) # AiErrorKind.QUOTA_EXCEEDED
|
|
1118
|
+
print(e.is_retryable) # True - retry next check
|
|
1119
|
+
print(e.is_permanent) # False - don't disable
|
|
1120
|
+
print(e.status_code) # 429
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1004
1123
|
## API reference
|
|
1005
1124
|
|
|
1006
1125
|
### `WatchDiff`
|
|
@@ -1055,6 +1174,12 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
|
|
|
1055
1174
|
| `on_spike` | `Callable \| None` | `None` | Called with `SpikeInfo` when spike is detected |
|
|
1056
1175
|
| `alert_on_status_change` | `bool` | `False` | Alert when HTTP status code changes (200→503, etc.) |
|
|
1057
1176
|
| `on_status_change` | `Callable \| None` | `None` | Called with `StatusChangeInfo` on status code change |
|
|
1177
|
+
| `alert_if` | `Callable[[DiffReport], bool] \| None` | `None` | Custom gate - only alert when this function returns `True` |
|
|
1178
|
+
| `expected_status` | `int \| None` | `None` | Fire `on_error` when actual HTTP status differs from this value |
|
|
1179
|
+
| `track_response_time` | `bool` | `False` | Record response time in milliseconds in `DiffReport.response_time_ms` |
|
|
1180
|
+
| `ai_summary` | `bool` | `False` | Generate an AI natural-language summary of detected changes |
|
|
1181
|
+
| `ai_provider` | `AiProvider \| None` | `None` | AI provider to use. Auto-detected from env vars when `None` |
|
|
1182
|
+
| `ai_prompt` | `str \| Callable[[DiffReport], str] \| None` | `None` | Custom prompt sent to the AI instead of the default template |
|
|
1058
1183
|
|
|
1059
1184
|
```python
|
|
1060
1185
|
# Chainable
|
|
@@ -1103,6 +1228,64 @@ wd.watch_db("sqlite:///app.db", "orders",
|
|
|
1103
1228
|
wd.start()
|
|
1104
1229
|
```
|
|
1105
1230
|
|
|
1231
|
+
#### `.watch_file(path, *, ...)`
|
|
1232
|
+
|
|
1233
|
+
Watch a local file for changes. Accepts all the same options as `.watch()` (except browser-related ones). The path is converted to a `file://` URL internally. Content is compared as raw text, bypassing the HTML cleaner.
|
|
1234
|
+
|
|
1235
|
+
```python
|
|
1236
|
+
wd.watch_file("/etc/nginx/nginx.conf", interval=30, diff_mode="line",
|
|
1237
|
+
on_change=lambda r: print("Config changed:", r.changes))
|
|
1238
|
+
|
|
1239
|
+
wd.watch_file("/var/log/app.log", interval=5,
|
|
1240
|
+
alert_if=lambda r: any("ERROR" in (c.after or "") for c in r.changes))
|
|
1241
|
+
|
|
1242
|
+
wd.watch_file("/tmp/prices.txt", interval=2, ai_summary=True,
|
|
1243
|
+
on_change=lambda r: print(r.ai_summary))
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
#### `.watch_api(url, *, ...)`
|
|
1247
|
+
|
|
1248
|
+
Convenience wrapper over `.watch()` pre-configured for JSON API monitoring: sets `diff_mode="json"`, `track_response_time=True`, and `expected_status=200` by default.
|
|
1249
|
+
|
|
1250
|
+
```python
|
|
1251
|
+
wd.watch_api("https://api.example.com/prices",
|
|
1252
|
+
interval=60,
|
|
1253
|
+
on_change=lambda r: print(f"API changed, responded in {r.response_time_ms:.0f}ms"))
|
|
1254
|
+
|
|
1255
|
+
# Alert when response time exceeds 500ms
|
|
1256
|
+
wd.watch_api("https://api.example.com/health",
|
|
1257
|
+
interval=30,
|
|
1258
|
+
alert_if=lambda r: (r.response_time_ms or 0) > 500)
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
#### `.watch_cert(host, *, port=443, warning_days=30, ...)`
|
|
1262
|
+
|
|
1263
|
+
Monitor an SSL/TLS certificate for expiry and fingerprint changes.
|
|
1264
|
+
|
|
1265
|
+
| Parameter | Type | Default | Description |
|
|
1266
|
+
|---|---|---|---|
|
|
1267
|
+
| `hostname` | `str` | — | Hostname to connect to |
|
|
1268
|
+
| `port` | `int` | `443` | TLS port |
|
|
1269
|
+
| `interval` | `int` | `86400` | Seconds between checks |
|
|
1270
|
+
| `label` | `str \| None` | `hostname:port` | Human-readable name |
|
|
1271
|
+
| `warn_days_before_expiry` | `int` | `30` | Days before expiry to start firing `on_expiry` |
|
|
1272
|
+
| `alert_on_change` | `bool` | `True` | Fire `on_change` when cert fingerprint changes |
|
|
1273
|
+
| `alert_on_expiry` | `bool` | `True` | Fire `on_expiry` when cert is nearing expiry |
|
|
1274
|
+
| `on_expiry` | `Callable \| None` | `None` | Called with `CertExpiryInfo` when expiry is approaching |
|
|
1275
|
+
| `on_change` | `Callable \| None` | `None` | Called with `CertChangeInfo` when fingerprint changes |
|
|
1276
|
+
| `on_error` | `Callable \| None` | `None` | Called with `(exc, config)` on fetch error |
|
|
1277
|
+
|
|
1278
|
+
```python
|
|
1279
|
+
wd.watch_cert("api.example.com",
|
|
1280
|
+
warn_days_before_expiry=14,
|
|
1281
|
+
on_expiry=lambda i: print(f"Cert expires in {i.days_until_expiry} days"),
|
|
1282
|
+
on_change=lambda i: print("Cert fingerprint changed!", i.current_fingerprint))
|
|
1283
|
+
|
|
1284
|
+
statuses = wd.get_cert_statuses() # list[CertWatcherStatus]
|
|
1285
|
+
for s in statuses:
|
|
1286
|
+
print(s.hostname, s.last_check_at, s.days_until_expiry, s.is_expiring_soon)
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1106
1289
|
#### `.on_change(callback)`
|
|
1107
1290
|
|
|
1108
1291
|
Register a global callback called whenever any watched URL changes:
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "watchdiff-core"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "Lightweight web change monitoring library - clean diffs,
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Lightweight web change monitoring library - clean diffs, AI summaries, file/API/SSL/DB monitoring, structured alerts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "BSD-2-Clause" }
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
from watchdiff.ai_summarizer import (
|
|
2
|
+
AiError,
|
|
3
|
+
AiErrorKind,
|
|
4
|
+
AiProvider,
|
|
5
|
+
ai_summary_enabled,
|
|
6
|
+
call_provider,
|
|
7
|
+
generate_ai_summary,
|
|
8
|
+
get_provider,
|
|
9
|
+
resolve_provider,
|
|
10
|
+
)
|
|
11
|
+
from watchdiff.cert_models import (
|
|
12
|
+
CertChangeInfo,
|
|
13
|
+
CertExpiryInfo,
|
|
14
|
+
CertReport,
|
|
15
|
+
CertSnapshot,
|
|
16
|
+
CertWatchConfig,
|
|
17
|
+
CertWatcherStatus,
|
|
18
|
+
make_cert_watch_config,
|
|
19
|
+
)
|
|
1
20
|
from watchdiff.core import WatchDiff
|
|
2
21
|
from watchdiff.db_models import (
|
|
3
22
|
DbChange,
|
|
@@ -16,12 +35,22 @@ from watchdiff.db_models import (
|
|
|
16
35
|
make_db_watch_config,
|
|
17
36
|
)
|
|
18
37
|
from watchdiff.exporter import Exporter
|
|
38
|
+
from watchdiff.file_fetcher import FileFetcher, file_path_from_url
|
|
19
39
|
from watchdiff.models import BrowserOptions, DiffMode, SpikeInfo, StatusChangeInfo, WatchConfig
|
|
20
40
|
from watchdiff.status_server import StatusServer
|
|
21
41
|
from watchdiff.store import SqliteStore, Store
|
|
22
42
|
|
|
23
43
|
__all__ = [
|
|
44
|
+
"AiError",
|
|
45
|
+
"AiErrorKind",
|
|
46
|
+
"AiProvider",
|
|
24
47
|
"BrowserOptions",
|
|
48
|
+
"CertChangeInfo",
|
|
49
|
+
"CertExpiryInfo",
|
|
50
|
+
"CertReport",
|
|
51
|
+
"CertSnapshot",
|
|
52
|
+
"CertWatchConfig",
|
|
53
|
+
"CertWatcherStatus",
|
|
25
54
|
"DbChange",
|
|
26
55
|
"DbChangeKind",
|
|
27
56
|
"DbDiffMode",
|
|
@@ -32,6 +61,7 @@ __all__ = [
|
|
|
32
61
|
"DbWatcherStatus",
|
|
33
62
|
"DiffMode",
|
|
34
63
|
"Exporter",
|
|
64
|
+
"FileFetcher",
|
|
35
65
|
"SchemaChangeInfo",
|
|
36
66
|
"SqliteStore",
|
|
37
67
|
"SpikeInfo",
|
|
@@ -41,8 +71,15 @@ __all__ = [
|
|
|
41
71
|
"ThresholdInfo",
|
|
42
72
|
"WatchConfig",
|
|
43
73
|
"WatchDiff",
|
|
74
|
+
"ai_summary_enabled",
|
|
75
|
+
"call_provider",
|
|
44
76
|
"db_has_changes",
|
|
45
77
|
"db_report_summary",
|
|
46
78
|
"db_snapshot_key",
|
|
79
|
+
"file_path_from_url",
|
|
80
|
+
"generate_ai_summary",
|
|
81
|
+
"get_provider",
|
|
82
|
+
"make_cert_watch_config",
|
|
47
83
|
"make_db_watch_config",
|
|
84
|
+
"resolve_provider",
|
|
48
85
|
]
|