watchdiff-core 0.2.3__tar.gz → 0.2.4__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.
Files changed (55) hide show
  1. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/PKG-INFO +351 -11
  2. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/README.md +350 -10
  3. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/pyproject.toml +1 -1
  4. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/tests/test_watchdiff.py +217 -2
  5. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/uv.lock +1 -1
  6. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/__init__.py +6 -0
  7. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cli/main.py +176 -5
  8. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/core.py +19 -3
  9. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/models.py +42 -1
  10. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/scheduler/scheduler.py +229 -34
  11. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/.github/workflows/ci.yml +0 -0
  12. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/.github/workflows/release-testpypi.yml +0 -0
  13. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/.github/workflows/release.yml +0 -0
  14. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/.gitignore +0 -0
  15. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/.python-version +0 -0
  16. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/LICENSE +0 -0
  17. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/main.py +0 -0
  18. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/tests/__init__.py +0 -0
  19. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/tests/test_db.py +0 -0
  20. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/ai_summarizer/__init__.py +0 -0
  21. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cert_fetcher/__init__.py +0 -0
  22. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cert_models.py +0 -0
  23. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cert_scheduler/__init__.py +0 -0
  24. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cleaner/__init__.py +0 -0
  25. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cleaner/cleaner.py +0 -0
  26. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cli/__init__.py +0 -0
  27. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/cron_parser/__init__.py +0 -0
  28. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_diff/__init__.py +0 -0
  29. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/__init__.py +0 -0
  30. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/mysql.py +0 -0
  31. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/postgres.py +0 -0
  32. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_fetcher/sqlite.py +0 -0
  33. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_models.py +0 -0
  34. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/db_scheduler/__init__.py +0 -0
  35. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/diff/__init__.py +0 -0
  36. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/diff/engine.py +0 -0
  37. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/exporter/__init__.py +0 -0
  38. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/exporter/exporter.py +0 -0
  39. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/fetcher/__init__.py +0 -0
  40. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/fetcher/browser.py +0 -0
  41. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/fetcher/fetcher.py +0 -0
  42. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/file_fetcher/__init__.py +0 -0
  43. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/json_path/__init__.py +0 -0
  44. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/mailer/__init__.py +0 -0
  45. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/notifier/__init__.py +0 -0
  46. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/notifier/notifier.py +0 -0
  47. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/parser/__init__.py +0 -0
  48. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/parser/parser.py +0 -0
  49. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/scheduler/__init__.py +0 -0
  50. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/sitemap/__init__.py +0 -0
  51. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/status_server/__init__.py +0 -0
  52. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/status_server/server.py +0 -0
  53. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/store/__init__.py +0 -0
  54. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/store/sqlite_store.py +0 -0
  55. {watchdiff_core-0.2.3 → watchdiff_core-0.2.4}/watchdiff/store/store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: watchdiff-core
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
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
@@ -92,6 +92,11 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
92
92
  | Monitor a sitemap for URL changes | `.watch_sitemap("https://example.com/sitemap.xml")` |
93
93
  | Watch one JSON field only | `json_path="$.data.price"` |
94
94
  | Monitor authenticated pages | `cookies={"session": "abc123", "csrftoken": "xyz"}` |
95
+ | Give a watcher a stable ID | `id="product-price"` — use ID instead of URL in `.pause()`/`.resume()` |
96
+ | Limit concurrent checks | `WatchDiff(concurrency=5)` — cap parallel fetch workers |
97
+ | Skip checks during downtime | `maintenance_windows=[MaintenanceWindow(from_="2026-01-15T02:00Z", to="2026-01-15T04:00Z")]` |
98
+ | Restrict checks to business hours | `active_between=ActiveBetween(from_="09:00", to="17:00", days=[0,1,2,3,4], timezone="Europe/Paris")` |
99
+ | Suppress noisy error alerts | `failure_policy=FailurePolicy(consecutive_failures=3, recovery_checks=2)` |
95
100
  | Send email on change | `alert=AlertConfig(email=EmailConfig(to="...", smtp=SmtpConfig(...)))` |
96
101
  | AI summary of changes | `ai_summary=True, ai_provider=AiProvider(type="gemini", api_key="...")` |
97
102
  | Customize the AI prompt | `ai_prompt="Summarize in French."` or `ai_prompt=lambda r: ...` |
@@ -149,6 +154,11 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
149
154
  - [JSON path targeting](#json-path-targeting)
150
155
  - [Cookie-based authentication](#cookie-based-authentication)
151
156
  - [Email alerts](#email-alerts)
157
+ - [Watcher ID](#watcher-id)
158
+ - [Concurrency limiting](#concurrency-limiting)
159
+ - [Maintenance windows](#maintenance-windows)
160
+ - [Active hours](#active-hours)
161
+ - [Failure policy](#failure-policy)
152
162
  - [API reference](#api-reference)
153
163
  - [`.watch()`](#watchurl--)
154
164
  - [`.watch_db()`](#watch_dbconnection_string-table--)
@@ -156,8 +166,13 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
156
166
  - [`.watch_api()`](#watch_apiurl--)
157
167
  - [`.watch_cert()`](#watch_certhost--port443-warning_days30-)
158
168
  - [`.watch_sitemap()`](#watch_sitemapurl--)
159
- - [`.start()` / `start_async()`](#startblock--startasync)
160
- - [`.stop()` / pause / resume / status](#stop--pause--resume--status)
169
+ - [`.on_change()`](#on_changecallback)
170
+ - [`.start()` / `start_async()` / `.stop()`](#startblocktrue--await-start_async--stop)
171
+ - [`.check_once()`](#check_onceurl)
172
+ - [`.compare_urls()`](#compare_urlsurl_a-url_b--)
173
+ - [`.start_status_server()` / `.stop_status_server()`](#start_status_serverport-host--stop_status_server)
174
+ - [`.pause()` / `.resume()` / `.status()` / `.db_status()`](#pauseurl--resumeurl--status--db_status)
175
+ - [`.history()` / `.reports()` / `.clear()`](#historyurl--reportsurl--clearurl)
161
176
  - [`DiffReport`](#diffreport)
162
177
  - [`Change`](#change)
163
178
  - [`Snapshot`](#snapshot)
@@ -165,6 +180,14 @@ WatchDiff watches web pages, files, APIs, databases, and SSL certificates - then
165
180
  - [`SilenceInfo`](#silenceinfo)
166
181
  - [`AlertConfig`](#alertconfig)
167
182
  - [`BrowserOptions`](#browseroptions)
183
+ - [`SpikeInfo`](#spikeinfo)
184
+ - [`StatusChangeInfo`](#statuschangeinfo)
185
+ - [`StatusServer`](#statusserver)
186
+ - [`EmailConfig` / `SmtpConfig`](#emailconfig--smtpconfig)
187
+ - [`SitemapDiffReport` / `SitemapEntry`](#sitemapdifreport--sitemapentry)
188
+ - [`MaintenanceWindow`](#maintenancewindow)
189
+ - [`ActiveBetween`](#activebetween)
190
+ - [`FailurePolicy`](#failurepolicy)
168
191
  - [`DbDiffReport`](#dbdiffreport)
169
192
  - [`DbChange`](#dbchange)
170
193
  - [`DbWatcherStatus`](#dbwatcherstatus)
@@ -297,6 +320,9 @@ watchdiff db "postgresql://user:pass@localhost/mydb" products \
297
320
  # Generate a config file
298
321
  watchdiff init
299
322
 
323
+ # Validate a config file without starting watchers
324
+ watchdiff validate watchdiff.config.json
325
+
300
326
  # Run from config file
301
327
  watchdiff run --config watchdiff.config.json
302
328
 
@@ -1089,7 +1115,15 @@ Edit `watchdiff.config.json`:
1089
1115
  "ignore_selectors": [".cookie-banner", "#ad-container"],
1090
1116
  "ignore_patterns": ["\\d+ views"],
1091
1117
  "timeout": 15,
1092
- "headers": {}
1118
+ "headers": {},
1119
+ "schedule": null,
1120
+ "confirm_after": null,
1121
+ "json_path": null,
1122
+ "email": null,
1123
+ "id": null,
1124
+ "maintenance_windows": [],
1125
+ "active_between": null,
1126
+ "failure_policy": null
1093
1127
  },
1094
1128
  {
1095
1129
  "url": "https://hnrss.org/frontpage",
@@ -1424,32 +1458,191 @@ wd.watch_api("https://api.example.com/account",
1424
1458
 
1425
1459
  Send email notifications via SMTP when a change is detected. Uses Python stdlib `smtplib` — no extra dependency required.
1426
1460
 
1461
+ ### Quickstart — `email=` shortcut
1462
+
1463
+ Pass an `EmailConfig` directly to `.watch()`:
1464
+
1427
1465
  ```python
1428
1466
  from watchdiff import WatchDiff
1429
- from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1467
+ from watchdiff.models import EmailConfig, SmtpConfig
1430
1468
 
1431
1469
  wd = WatchDiff()
1470
+ wd.watch(
1471
+ "https://example.com/prices",
1472
+ email=EmailConfig(
1473
+ to="alerts@example.com",
1474
+ smtp=SmtpConfig(
1475
+ host="smtp.gmail.com",
1476
+ port=465,
1477
+ user="you@gmail.com",
1478
+ password="app-password",
1479
+ ),
1480
+ ),
1481
+ )
1482
+ wd.start()
1483
+ ```
1484
+
1485
+ ### Via `AlertConfig` — combine with webhooks and callbacks
1486
+
1487
+ ```python
1488
+ from watchdiff.models import AlertConfig, EmailConfig, SmtpConfig
1489
+
1432
1490
  wd.watch("https://example.com/prices",
1433
1491
  alert=AlertConfig(
1434
- on_change=[],
1435
- webhooks=[],
1492
+ on_change=[lambda r: print(r.summary())],
1493
+ webhooks=["https://ntfy.sh/my-topic"],
1436
1494
  min_changes=1,
1437
1495
  email=EmailConfig(
1438
- to="alerts@example.com",
1496
+ to=["alerts@example.com", "team@example.com"],
1497
+ from_="watchdiff@example.com",
1498
+ subject="Price change detected",
1439
1499
  smtp=SmtpConfig(
1440
1500
  host="smtp.gmail.com",
1441
- port=465,
1501
+ port=587, # STARTTLS
1442
1502
  user="you@gmail.com",
1443
1503
  password="app-password",
1444
1504
  ),
1445
1505
  ),
1446
1506
  ))
1507
+ ```
1508
+
1509
+ ### Config file (`watchdiff.config.json`)
1510
+
1511
+ ```json
1512
+ {
1513
+ "url": "https://example.com/prices",
1514
+ "email": {
1515
+ "to": "alerts@example.com",
1516
+ "from": "watchdiff@example.com",
1517
+ "subject": "Price change detected",
1518
+ "smtp": {
1519
+ "host": "smtp.gmail.com",
1520
+ "port": 465,
1521
+ "user": "you@gmail.com",
1522
+ "password": "app-password"
1523
+ }
1524
+ }
1525
+ }
1526
+ ```
1527
+
1528
+ **Notes:**
1529
+ - `from_` defaults to `user@host` when omitted
1530
+ - `subject` defaults to `"[WatchDiff] Change detected: {label}"`
1531
+ - Multiple recipients: `to=["a@x.com", "b@x.com"]`
1532
+ - Port `465` uses SSL from the start (`SMTP_SSL`). All other ports use `STARTTLS`.
1533
+
1534
+ ## Watcher ID
1535
+
1536
+ Assign a stable string identifier to any watcher. The ID is used instead of the URL for `.pause()`, `.resume()`, and `.status()` lookups, which is useful when two watchers share the same URL or when you want readable keys in your control code.
1537
+
1538
+ ```python
1539
+ from watchdiff import WatchDiff
1540
+
1541
+ wd = WatchDiff()
1542
+ wd.watch("https://example.com/prices", id="product-price", interval=60)
1543
+ wd.watch("https://example.com/prices", id="product-price-2", interval=120)
1544
+
1545
+ wd.pause("product-price") # pause by ID, not URL
1546
+ wd.resume("product-price")
1547
+ ```
1548
+
1549
+ Without `id`, `pause(url)` pauses **all** watchers registered for that URL. With `id`, each watcher is independent and can be paused or resumed individually.
1550
+
1551
+ IDs also appear in `WatcherStatus.id` and in the JSON returned by `status()`.
1552
+
1553
+ ## Concurrency limiting
1554
+
1555
+ Cap the number of checks that run simultaneously. Without a limit all watchers fire in parallel; with a limit the scheduler queues excess checks.
1556
+
1557
+ ```python
1558
+ wd = WatchDiff(concurrency=5) # at most 5 simultaneous fetches
1559
+ ```
1560
+
1561
+ Useful when you monitor hundreds of URLs and want to avoid hammering a shared proxy pool or saturating a network interface.
1562
+
1563
+ ## Maintenance windows
1564
+
1565
+ Skip checks during a known downtime window. Checks resume automatically once the window closes.
1566
+
1567
+ ```python
1568
+ from watchdiff import WatchDiff, MaintenanceWindow
1569
+
1570
+ wd = WatchDiff()
1571
+ wd.watch(
1572
+ "https://api.example.com/health",
1573
+ interval=60,
1574
+ maintenance_windows=[
1575
+ MaintenanceWindow(
1576
+ from_="2026-02-01T02:00:00+00:00", # ISO 8601 UTC start
1577
+ to="2026-02-01T04:00:00+00:00", # UTC end
1578
+ ),
1579
+ ],
1580
+ )
1581
+ wd.start()
1582
+ ```
1583
+
1584
+ - `from_` and `to` accept either an ISO 8601 string or a `datetime` object.
1585
+ - Multiple windows can be listed; any overlapping window suppresses the check.
1586
+ - The window is evaluated fresh on each tick — no restart needed after the window passes.
1587
+ - `WatcherStatus.in_maintenance` reflects the current state.
1588
+
1589
+ ## Active hours
1590
+
1591
+ Restrict checks to specific hours and/or days. Checks outside the window are silently skipped until the next window opens.
1592
+
1593
+ ```python
1594
+ from watchdiff import WatchDiff, ActiveBetween
1595
+
1596
+ wd = WatchDiff()
1597
+ wd.watch(
1598
+ "https://example.com/prices",
1599
+ interval=60,
1600
+ active_between=ActiveBetween(
1601
+ from_="09:00", # "HH:MM" — start of active window
1602
+ to="17:00", # "HH:MM" — end of active window
1603
+ days=[0, 1, 2, 3, 4], # Mon–Fri (0=Monday, 6=Sunday). None = every day
1604
+ timezone="Europe/Paris", # IANA timezone name. None = UTC
1605
+ ),
1606
+ )
1607
+ wd.start()
1608
+ ```
1609
+
1610
+ - `to` before `from_` (e.g. `from_="22:00"`, `to="06:00"`) is treated as an overnight window.
1611
+ - `days` defaults to all 7 days when omitted. `timezone` defaults to UTC when omitted.
1612
+ - Requires Python 3.9+ `zoneinfo` stdlib. Falls back to UTC if the IANA database is unavailable.
1613
+ - **Note:** the Python API uses integer weekday indices (`0`=Monday … `6`=Sunday, matching `datetime.weekday()`). The TypeScript port uses string names (`"monday"`, `"friday"`, …).
1614
+
1615
+ ## Failure policy
1616
+
1617
+ Gate the `on_error` callback until a URL has failed a configurable number of times in a row, and require a configurable number of consecutive successes before considering it recovered. Reduces alert noise from transient blips.
1618
+
1619
+ ```python
1620
+ from watchdiff import WatchDiff, FailurePolicy
1621
+
1622
+ wd = WatchDiff()
1623
+ wd.watch(
1624
+ "https://example.com/status",
1625
+ interval=30,
1626
+ failure_policy=FailurePolicy(
1627
+ consecutive_failures=3, # only fire on_error after 3 failures in a row
1628
+ recovery_checks=2, # require 2 consecutive successes to clear the failure state
1629
+ respect_retry_after=True, # honour Retry-After response header when present
1630
+ ),
1631
+ on_error=lambda exc, cfg: print(f"Confirmed failure: {exc}"),
1632
+ )
1447
1633
  wd.start()
1448
1634
  ```
1449
1635
 
1450
- Optional fields: `from_` (defaults to `user@host`), `subject` (defaults to `"[WatchDiff] Change detected: {label}"`). Multiple recipients: `to=["a@x.com", "b@x.com"]`.
1636
+ Without `failure_policy`, the first fetch error fires `on_error` immediately. With it:
1637
+
1638
+ - Errors below the threshold are silently swallowed — no callback, no alert.
1639
+ - After `consecutive_failures` errors in a row, `on_error` fires **once**, then is suppressed until recovery.
1640
+ - Recovery requires `recovery_checks` consecutive successful fetches.
1641
+ - When `respect_retry_after=True` and the server returns a `Retry-After` header (e.g. on 429/503), the next check is postponed until the header deadline.
1451
1642
 
1452
- Port `465` uses SSL from the start (`SMTP_SSL`). Other ports use `STARTTLS`.
1643
+ Default: `consecutive_failures=3`, `recovery_checks=1`, `respect_retry_after=False`.
1644
+
1645
+ > **Note:** the TypeScript port defaults `consecutiveFailures` to **1** (fire on the first error). Python defaults to **3**. A bare `FailurePolicy()` therefore behaves differently across the two implementations.
1453
1646
 
1454
1647
  ## API reference
1455
1648
 
@@ -1462,8 +1655,15 @@ from watchdiff.store import SqliteStore
1462
1655
  wd = WatchDiff() # JSON store in .watchdiff/
1463
1656
  wd = WatchDiff(storage_dir="/data/watchdiff") # custom JSON store path
1464
1657
  wd = WatchDiff(store=SqliteStore("db.sqlite")) # SQLite store
1658
+ wd = WatchDiff(concurrency=5) # at most 5 parallel fetch workers
1465
1659
  ```
1466
1660
 
1661
+ | Parameter | Type | Default | Description |
1662
+ |---|---|---|---|
1663
+ | `storage_dir` | `str` | `".watchdiff"` | Directory for JSON snapshot/report files |
1664
+ | `store` | `Store \| None` | `None` | Custom store implementation (e.g. `SqliteStore`) |
1665
+ | `concurrency` | `int \| None` | `None` | Max simultaneous check workers. `None` = unlimited |
1666
+
1467
1667
  #### `.watch(url, *, ...)`
1468
1668
 
1469
1669
  Register a URL to monitor. All keyword arguments are optional. Returns `self` (chainable).
@@ -1516,6 +1716,11 @@ Register a URL to monitor. All keyword arguments are optional. Returns `self` (c
1516
1716
  | `schedule` | `str \| None` | `None` | 5-field cron expression. Overrides `interval` when set. |
1517
1717
  | `confirm_after` | `int \| None` | `None` | Re-verify after N seconds before alerting (flapping detection) |
1518
1718
  | `json_path` | `str \| None` | `None` | JSON path expression to extract a sub-value before diffing (e.g. `"$.data.price"`) |
1719
+ | `email` | `EmailConfig \| None` | `None` | SMTP email alert fired on every detected change — shortcut over building a full `AlertConfig` |
1720
+ | `id` | `str \| None` | `None` | Stable identifier for this watcher — used instead of URL in `.pause()`/`.resume()` |
1721
+ | `maintenance_windows` | `list[MaintenanceWindow]` | `[]` | One-time UTC time ranges during which checks are skipped |
1722
+ | `active_between` | `ActiveBetween \| None` | `None` | Restrict checks to a recurring daily/weekly time window |
1723
+ | `failure_policy` | `FailurePolicy \| None` | `None` | Gate `on_error` until N consecutive failures; require M consecutive successes to recover |
1519
1724
 
1520
1725
  ```python
1521
1726
  # Chainable
@@ -1823,6 +2028,8 @@ status.checks_count # int
1823
2028
  status.changes_count # int
1824
2029
  status.errors_count # int
1825
2030
  status.last_status_code # int — last known HTTP status (0 = unknown)
2031
+ status.id # str | None — stable watcher ID (set via id= on .watch())
2032
+ status.in_maintenance # bool — True if currently inside a maintenance window
1826
2033
 
1827
2034
  status.as_dict() # JSON-serialisable dict
1828
2035
  ```
@@ -1891,6 +2098,128 @@ server.start()
1891
2098
  server.stop()
1892
2099
  ```
1893
2100
 
2101
+ ### `EmailConfig` / `SmtpConfig`
2102
+
2103
+ ```python
2104
+ from watchdiff import EmailConfig, SmtpConfig
2105
+
2106
+ email = EmailConfig(
2107
+ to="ops@example.com", # str or list[str]
2108
+ smtp=SmtpConfig(
2109
+ host="smtp.example.com",
2110
+ port=587,
2111
+ user="alerts@example.com",
2112
+ password="secret",
2113
+ secure=None, # None = auto (SSL on 465, STARTTLS otherwise)
2114
+ ),
2115
+ from_="alerts@example.com", # optional sender address
2116
+ subject="[WatchDiff] change", # optional subject override
2117
+ )
2118
+ ```
2119
+
2120
+ Pass as `email=` to `.watch()` or inside `AlertConfig(email=...)`.
2121
+
2122
+ ---
2123
+
2124
+ ### `SitemapDiffReport` / `SitemapEntry`
2125
+
2126
+ Passed to callbacks registered with `.watch_sitemap()`:
2127
+
2128
+ ```python
2129
+ report.sitemap_url # str — URL of the sitemap
2130
+ report.label # str — human-readable label
2131
+ report.added # list[SitemapEntry] — URLs newly present in the sitemap
2132
+ report.removed # list[SitemapEntry] — URLs no longer in the sitemap
2133
+ report.compared_at # datetime — UTC timestamp of the comparison
2134
+ ```
2135
+
2136
+ Each `SitemapEntry`:
2137
+
2138
+ ```python
2139
+ entry.url # str
2140
+ entry.last_modified # str | None — <lastmod> value from the sitemap
2141
+ entry.change_freq # str | None — <changefreq> value
2142
+ entry.priority # str | None — <priority> value
2143
+ ```
2144
+
2145
+ ---
2146
+
2147
+ ### `MaintenanceWindow`
2148
+
2149
+ Defines a one-time UTC window during which checks are paused:
2150
+
2151
+ ```python
2152
+ from watchdiff import MaintenanceWindow
2153
+ from datetime import datetime, timezone
2154
+
2155
+ # From ISO 8601 strings (recommended)
2156
+ w = MaintenanceWindow(
2157
+ from_="2026-02-01T02:00:00+00:00",
2158
+ to="2026-02-01T04:00:00+00:00",
2159
+ )
2160
+
2161
+ # From datetime objects
2162
+ w = MaintenanceWindow(
2163
+ from_=datetime(2026, 2, 1, 2, 0, tzinfo=timezone.utc),
2164
+ to=datetime(2026, 2, 1, 4, 0, tzinfo=timezone.utc),
2165
+ )
2166
+ ```
2167
+
2168
+ | Field | Type | Description |
2169
+ |---|---|---|
2170
+ | `from_` | `datetime \| str` | UTC start; ISO 8601 string or `datetime` |
2171
+ | `to` | `datetime \| str` | UTC end |
2172
+
2173
+ ISO strings are parsed in `__post_init__`. `"Z"` suffix is accepted as `+00:00`.
2174
+
2175
+ ---
2176
+
2177
+ ### `ActiveBetween`
2178
+
2179
+ Restricts checks to a recurring daily or weekly time window:
2180
+
2181
+ ```python
2182
+ from watchdiff import ActiveBetween
2183
+
2184
+ ab = ActiveBetween(
2185
+ from_="09:00",
2186
+ to="17:00",
2187
+ days=[0, 1, 2, 3, 4], # 0=Monday … 6=Sunday. None = every day
2188
+ timezone="Europe/Paris", # IANA name. None = UTC
2189
+ )
2190
+ ```
2191
+
2192
+ | Field | Type | Default | Description |
2193
+ |---|---|---|---|
2194
+ | `from_` | `str` | — | Window start as `"HH:MM"` |
2195
+ | `to` | `str` | — | Window end as `"HH:MM"`. If before `from_`, treated as overnight |
2196
+ | `days` | `list[int] \| None` | `None` | Weekdays to restrict to (`0`=Mon). `None` = every day |
2197
+ | `timezone` | `str \| None` | `None` | IANA timezone name (e.g. `"America/New_York"`). `None` = UTC |
2198
+
2199
+ ---
2200
+
2201
+ ### `FailurePolicy`
2202
+
2203
+ Gates `on_error` to avoid alert noise from transient blips:
2204
+
2205
+ ```python
2206
+ from watchdiff import FailurePolicy
2207
+
2208
+ fp = FailurePolicy(
2209
+ consecutive_failures=3, # fire on_error only after this many consecutive failures
2210
+ recovery_checks=2, # require this many consecutive successes to clear failure state
2211
+ respect_retry_after=True, # honour Retry-After response header if present
2212
+ )
2213
+ ```
2214
+
2215
+ | Field | Type | Default | Description |
2216
+ |---|---|---|---|
2217
+ | `consecutive_failures` | `int` | `3` | Minimum consecutive failures before `on_error` fires (TypeScript default: `1`) |
2218
+ | `recovery_checks` | `int` | `1` | Consecutive successes required to exit failure mode |
2219
+ | `respect_retry_after` | `bool` | `False` | Use `Retry-After` header delay for the next check |
2220
+
2221
+ ---
2222
+
1894
2223
  ### `DbDiffReport`
1895
2224
 
1896
2225
  Returned by `on_change` and `DbDiffEngine.compare()`:
@@ -1994,6 +2323,7 @@ summary = db_report_summary(report) # "orders: 1 inserted"
1994
2323
  ```
1995
2324
  Commands:
1996
2325
  init Generate a watchdiff.config.json template
2326
+ validate Check a config file for errors without starting any watchers
1997
2327
  run Start continuous monitoring (URL or config file)
1998
2328
  db Monitor a database table for changes
1999
2329
  compare Fetch two URLs and compare their content
@@ -2028,11 +2358,18 @@ Options for run:
2028
2358
  --alert-if-no-change Fire silence alert after N seconds without change (0 = off)
2029
2359
  --proxy Proxy URL (repeatable)
2030
2360
  --user-agent User-Agent string (repeatable)
2361
+ --schedule 5-field cron expression — overrides --interval when set
2362
+ --confirm-after Re-fetch after N seconds before confirming a change (0 = off)
2363
+ --json-path $.dot.path expression to extract from JSON response before diffing
2031
2364
  --webhook -w Webhook URL (repeatable)
2032
2365
  --log-format Log format: text | json (default text)
2033
2366
  --verbose -v Enable debug logging
2034
2367
  --quiet -q Suppress change output
2035
2368
 
2369
+ Options for validate:
2370
+ --json Output result as JSON (exit code 0 = valid, 1 = invalid)
2371
+ --verbose -v Enable debug logging
2372
+
2036
2373
  Options for db:
2037
2374
  --diff-mode -m row | schema | aggregate | value (default row)
2038
2375
  --interval -i Seconds between checks (default 60)
@@ -2112,6 +2449,9 @@ Every CLI option can be set via environment variable — useful for Docker, CI,
2112
2449
  | `WATCHDIFF_ALERT_IF_NO_CHANGE` | `--alert-if-no-change` | `86400` |
2113
2450
  | `WATCHDIFF_PROXY` | `--proxy` | `http://proxy:8080` |
2114
2451
  | `WATCHDIFF_USER_AGENT` | `--user-agent` | `MyBot/1.0` |
2452
+ | `WATCHDIFF_SCHEDULE` | `--schedule` | `0 9 * * *` |
2453
+ | `WATCHDIFF_CONFIRM_AFTER` | `--confirm-after` | `30` |
2454
+ | `WATCHDIFF_JSON_PATH` | `--json-path` | `$.data.price` |
2115
2455
  | `WATCHDIFF_TARGET` | `--target` | `.price` |
2116
2456
  | `WATCHDIFF_QUIET` | `--quiet` | `true` |
2117
2457
  | `WATCHDIFF_LOG_FORMAT` | `--log-format` | `json` |