schemawatch-core 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.
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from schemawatch.diff import Change, DriftEvent, diff
6
+ from schemawatch.infer import SchemaNode, infer_schema, merge
7
+ from schemawatch.redact import DYNAMIC_KEY_WILDCARD, DynamicKeyWildcard, redact
8
+ from schemawatch.severity import (
9
+ KIND_BECAME_NULLABLE,
10
+ KIND_ENUM_CHANGED,
11
+ KIND_FIELD_ADDED,
12
+ KIND_FIELD_MISSING,
13
+ KIND_TYPE_CHANGED,
14
+ SEVERITY_BREAKING,
15
+ SEVERITY_INFO,
16
+ SEVERITY_WARNING,
17
+ calculate_severity,
18
+ )
19
+ from schemawatch.store import MemoryStore, RedisStore, Store
20
+ from schemawatch.watchdog import Watchdog
21
+
22
+ if TYPE_CHECKING:
23
+ from schemawatch.alerts import SlackAlert, WebhookAlert, fan_out
24
+ from schemawatch.integrations.httpx_hook import SchemawatchHTTPXHook, create_httpx_hook
25
+
26
+ __all__ = [
27
+ "Change",
28
+ "DYNAMIC_KEY_WILDCARD",
29
+ "DriftEvent",
30
+ "DynamicKeyWildcard",
31
+ "KIND_BECAME_NULLABLE",
32
+ "KIND_ENUM_CHANGED",
33
+ "KIND_FIELD_ADDED",
34
+ "KIND_FIELD_MISSING",
35
+ "KIND_TYPE_CHANGED",
36
+ "MemoryStore",
37
+ "RedisStore",
38
+ "SEVERITY_BREAKING",
39
+ "SEVERITY_INFO",
40
+ "SEVERITY_WARNING",
41
+ "SchemaNode",
42
+ "SchemawatchHTTPXHook",
43
+ "SlackAlert",
44
+ "Store",
45
+ "Watchdog",
46
+ "WebhookAlert",
47
+ "calculate_severity",
48
+ "create_httpx_hook",
49
+ "diff",
50
+ "fan_out",
51
+ "infer_schema",
52
+ "merge",
53
+ "redact",
54
+ ]
55
+
56
+
57
+ def __getattr__(name: str) -> Any:
58
+ """Lazy module exports so 'import schemawatch' works without httpx installed."""
59
+ if name in ("SlackAlert", "WebhookAlert", "fan_out"):
60
+ import schemawatch.alerts as alerts
61
+
62
+ return getattr(alerts, name)
63
+ if name in ("SchemawatchHTTPXHook", "create_httpx_hook"):
64
+ import schemawatch.integrations.httpx_hook as httpx_hook
65
+
66
+ return getattr(httpx_hook, name)
67
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
68
+
69
+
70
+ def __dir__() -> list[str]:
71
+ return sorted(list(globals().keys()) + __all__)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+
6
+ from schemawatch.alerts.slack import SlackAlert
7
+ from schemawatch.alerts.webhook import WebhookAlert
8
+ from schemawatch.diff import Change, DriftEvent
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def fan_out(
14
+ *dispatchers: Callable[[DriftEvent], None],
15
+ ) -> Callable[[DriftEvent | Change], None]:
16
+ """Combine multiple alert dispatchers into a single fail-open callback.
17
+
18
+ Dispatches sequentially to each handler. If one handler raises an exception,
19
+ the exception is logged and swallowed so subsequent handlers still execute.
20
+ """
21
+
22
+ def _composite(event: DriftEvent | Change) -> None:
23
+ drift_event = (
24
+ event
25
+ if isinstance(event, DriftEvent)
26
+ else DriftEvent(vendor="unknown", endpoint="unknown", changes=(event,))
27
+ )
28
+ for dispatcher in dispatchers:
29
+ try:
30
+ dispatcher(drift_event)
31
+ except Exception as exc:
32
+ logger.exception("schemawatch fan_out: dispatcher failed: %s", exc)
33
+
34
+ return _composite
35
+
36
+
37
+ __all__ = [
38
+ "SlackAlert",
39
+ "WebhookAlert",
40
+ "fan_out",
41
+ ]
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import urllib.error
5
+ from typing import Final
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ RETRYABLE_STATUS_CODES: Final[frozenset[int]] = frozenset({408, 425, 429})
10
+ MAX_RETRY_AFTER_SECONDS: Final[float] = 30.0
11
+
12
+
13
+ def should_retry_error(exc: Exception) -> tuple[bool, float | None]:
14
+ """Determine whether an HTTP/network exception should be retried.
15
+
16
+ Rules:
17
+ - Network errors (URLError, OSError, TimeoutError) are retried.
18
+ - HTTP status codes 408, 425, 429, and 5xx are retried.
19
+ - Other 4xx status codes (400, 401, 403, 404, etc.) are NOT retried.
20
+ - If a Retry-After header is present on a retryable HTTP response, its value
21
+ is honoured as seconds and capped at 30 seconds.
22
+
23
+ Returns:
24
+ (should_retry, retry_after_delay_in_seconds)
25
+ """
26
+ if isinstance(exc, urllib.error.HTTPError):
27
+ code = exc.code
28
+ if code in RETRYABLE_STATUS_CODES or code >= 500:
29
+ retry_after: float | None = None
30
+ if exc.headers:
31
+ raw_ra = exc.headers.get("Retry-After")
32
+ if raw_ra:
33
+ try:
34
+ retry_after = min(max(0.0, float(raw_ra)), MAX_RETRY_AFTER_SECONDS)
35
+ except ValueError:
36
+ retry_after = None
37
+ return True, retry_after
38
+ # Other 4xx are client errors and should NOT be retried
39
+ if 400 <= code < 500:
40
+ return False, None
41
+ return False, None
42
+
43
+ if isinstance(exc, (urllib.error.URLError, OSError, TimeoutError)):
44
+ return True, None
45
+
46
+ return False, None
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ import urllib.error
7
+ import urllib.parse
8
+ import urllib.request
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from schemawatch.alerts.retry import should_retry_error
13
+ from schemawatch.diff import Change, DriftEvent
14
+ from schemawatch.severity import SEVERITY_BREAKING, SEVERITY_INFO, SEVERITY_WARNING
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SLACK_COLOR_MAP: dict[str, str] = {
19
+ SEVERITY_BREAKING: "#E01E5A", # Red / Danger
20
+ SEVERITY_WARNING: "#ECB22E", # Yellow / Warning
21
+ SEVERITY_INFO: "#2F80ED", # Blue / Info
22
+ }
23
+ SLACK_COLOR_DEFAULT = "#2F80ED" # Blue / Info
24
+
25
+
26
+ class SlackAlert:
27
+ """Fail-open Slack incoming webhook dispatcher with rich formatting.
28
+
29
+ Maps schema drift changes to colored Slack attachments.
30
+ Never raises exceptions into host application and never logs raw webhook URLs.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ webhook_url: str,
36
+ *,
37
+ channel: str | None = None,
38
+ timeout: float = 5.0,
39
+ max_retries: int = 2,
40
+ backoff_seconds: float = 0.5,
41
+ sleep_fn: Callable[[float], None] = time.sleep,
42
+ ) -> None:
43
+ parsed = urllib.parse.urlparse(webhook_url)
44
+ if parsed.scheme not in ("http", "https"):
45
+ raise ValueError(
46
+ f"Invalid alert webhook URL scheme: {parsed.scheme!r}. Must be http or https."
47
+ )
48
+ self._url = webhook_url
49
+ self._channel = channel
50
+ self._timeout = timeout
51
+ self._max_retries = max_retries
52
+ self._backoff = backoff_seconds
53
+ self._sleep_fn = sleep_fn
54
+ self.last_status_code: int | None = None
55
+ self.last_response_body: str | None = None
56
+ self.last_attempts: int = 0
57
+
58
+ def __repr__(self) -> str:
59
+ return f"SlackAlert(webhook_url='***redacted***', channel={self._channel!r})"
60
+
61
+ def __call__(self, event: DriftEvent | Change) -> None:
62
+ """Dispatch drift event alert to Slack."""
63
+ self.send(event)
64
+
65
+ def _build_payload(self, event: DriftEvent) -> dict[str, Any]:
66
+ header_text = f":warning: API Contract Drift Detected: *{event.vendor}* `{event.endpoint}`"
67
+
68
+ # Group changes by severity in priority order
69
+ severity_order = [SEVERITY_BREAKING, SEVERITY_WARNING, SEVERITY_INFO]
70
+ changes_by_sev: dict[str, list[Change]] = {sev: [] for sev in severity_order}
71
+
72
+ for ch in event.changes:
73
+ if ch.severity in changes_by_sev:
74
+ changes_by_sev[ch.severity].append(ch)
75
+ else:
76
+ changes_by_sev[SEVERITY_INFO].append(ch)
77
+
78
+ attachments: list[dict[str, Any]] = []
79
+ for sev in severity_order:
80
+ sev_changes = changes_by_sev[sev]
81
+ if not sev_changes:
82
+ continue
83
+
84
+ color = SLACK_COLOR_MAP.get(sev, SLACK_COLOR_DEFAULT)
85
+ fields: list[dict[str, Any]] = []
86
+ for ch in sev_changes:
87
+ desc = ch.detail or (f"`{ch.was}` -> `{ch.now}`" if ch.was or ch.now else ch.kind)
88
+ fields.append(
89
+ {
90
+ "title": f"`{ch.field}`",
91
+ "value": f"*{ch.kind}*: {desc}",
92
+ "short": len(ch.field) <= 24,
93
+ }
94
+ )
95
+
96
+ count_str = f" ({len(sev_changes)})" if len(sev_changes) > 1 else ""
97
+ attachments.append(
98
+ {
99
+ "color": color,
100
+ "title": f"[{sev.upper()}]{count_str}",
101
+ "fields": fields,
102
+ "ts": int(event.detected_at),
103
+ }
104
+ )
105
+
106
+ payload: dict[str, Any] = {
107
+ "text": header_text,
108
+ "attachments": attachments,
109
+ }
110
+ if self._channel:
111
+ payload["channel"] = self._channel
112
+ return payload
113
+
114
+ def send(self, event: DriftEvent | Change) -> bool:
115
+ """Send formatted Slack message with retries.
116
+
117
+ Returns True if successful, False otherwise. Never raises exceptions.
118
+ """
119
+ # Normalize Change to DriftEvent if passed directly
120
+ if isinstance(event, Change):
121
+ drift_event = DriftEvent(
122
+ vendor="unknown",
123
+ endpoint="unknown",
124
+ changes=(event,),
125
+ )
126
+ else:
127
+ drift_event = event
128
+
129
+ payload = self._build_payload(drift_event)
130
+ data = json.dumps(payload).encode("utf-8")
131
+ headers = {
132
+ "Content-Type": "application/json",
133
+ "User-Agent": "schemawatch-slack-alert/0.1.0",
134
+ }
135
+
136
+ self.last_status_code = None
137
+ self.last_response_body = None
138
+ self.last_attempts = 0
139
+
140
+ for attempt in range(1, self._max_retries + 2):
141
+ self.last_attempts = attempt
142
+ try:
143
+ req = urllib.request.Request(
144
+ self._url,
145
+ data=data,
146
+ headers=headers,
147
+ method="POST",
148
+ )
149
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # nosec B310
150
+ self.last_status_code = resp.status
151
+ self.last_response_body = resp.read().decode("utf-8", errors="replace")
152
+ if 200 <= resp.status < 300:
153
+ return True
154
+ except urllib.error.HTTPError as http_exc:
155
+ self.last_status_code = http_exc.code
156
+ try:
157
+ self.last_response_body = http_exc.read().decode("utf-8", errors="replace")
158
+ except Exception:
159
+ self.last_response_body = None
160
+ should_retry, retry_after = should_retry_error(http_exc)
161
+ logger.warning(
162
+ "schemawatch: slack alert delivery failed (attempt %d/%d): HTTP %d",
163
+ attempt,
164
+ self._max_retries + 1,
165
+ http_exc.code,
166
+ )
167
+ if not should_retry:
168
+ return False
169
+
170
+ if attempt <= self._max_retries:
171
+ delay = (
172
+ retry_after
173
+ if retry_after is not None
174
+ else self._backoff * (2 ** (attempt - 1))
175
+ )
176
+ self._sleep_fn(delay)
177
+ except Exception as exc:
178
+ should_retry, retry_after = should_retry_error(exc)
179
+ # Privacy rule: NEVER log the webhook URL
180
+ logger.warning(
181
+ "schemawatch: slack alert delivery failed (attempt %d/%d): %s",
182
+ attempt,
183
+ self._max_retries + 1,
184
+ type(exc).__name__,
185
+ )
186
+ if not should_retry:
187
+ return False
188
+
189
+ if attempt <= self._max_retries:
190
+ delay = (
191
+ retry_after
192
+ if retry_after is not None
193
+ else self._backoff * (2 ** (attempt - 1))
194
+ )
195
+ self._sleep_fn(delay)
196
+
197
+ logger.error(
198
+ "schemawatch: slack alert delivery exhausted all retries for %s %s",
199
+ drift_event.vendor,
200
+ drift_event.endpoint,
201
+ )
202
+ return False
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import logging
7
+ import time
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from schemawatch.alerts.retry import should_retry_error
15
+ from schemawatch.diff import Change, DriftEvent
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class WebhookAlert:
21
+ """Fail-open generic webhook alert dispatcher with retries.
22
+
23
+ Dispatches JSON alert payloads to a configured HTTP webhook endpoint.
24
+ Guaranteed never to raise exceptions into the host application and never
25
+ to log raw webhook URLs (preventing token/credential leaks).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ url: str,
31
+ *,
32
+ secret: str | None = None,
33
+ headers: dict[str, str] | None = None,
34
+ timeout: float = 5.0,
35
+ max_retries: int = 2,
36
+ backoff_seconds: float = 0.5,
37
+ sleep_fn: Callable[[float], None] = time.sleep,
38
+ ) -> None:
39
+ parsed = urllib.parse.urlparse(url)
40
+ if parsed.scheme not in ("http", "https"):
41
+ raise ValueError(
42
+ f"Invalid alert webhook URL scheme: {parsed.scheme!r}. Must be http or https."
43
+ )
44
+ self._url = url
45
+ self._secret = secret
46
+ self._headers = headers or {}
47
+ self._timeout = timeout
48
+ self._max_retries = max_retries
49
+ self._backoff = backoff_seconds
50
+ self._sleep_fn = sleep_fn
51
+ self.last_status_code: int | None = None
52
+ self.last_response_body: str | None = None
53
+ self.last_attempts: int = 0
54
+
55
+ def __repr__(self) -> str:
56
+ return f"WebhookAlert(url='***redacted***', timeout={self._timeout})"
57
+
58
+ def __call__(self, event: DriftEvent | Change) -> None:
59
+ """Dispatch drift event alert to the webhook."""
60
+ self.send(event)
61
+
62
+ def send(self, event: DriftEvent | Change) -> bool:
63
+ """Send drift event alert with retries.
64
+
65
+ Returns True if successful, False otherwise. Never raises exceptions.
66
+ """
67
+ # Normalize single Change to DriftEvent if passed directly
68
+ if isinstance(event, Change):
69
+ drift_event = DriftEvent(
70
+ vendor="unknown",
71
+ endpoint="unknown",
72
+ changes=(event,),
73
+ )
74
+ else:
75
+ drift_event = event
76
+
77
+ payload: dict[str, Any] = {
78
+ "event": "schema_drift",
79
+ "vendor": drift_event.vendor,
80
+ "endpoint": drift_event.endpoint,
81
+ "highest_severity": drift_event.highest_severity,
82
+ "detected_at": drift_event.detected_at,
83
+ "changes": [c.to_dict() for c in drift_event.changes],
84
+ }
85
+
86
+ data = json.dumps(payload).encode("utf-8")
87
+ req_headers = {
88
+ "Content-Type": "application/json",
89
+ "User-Agent": "schemawatch-alert/0.1.0",
90
+ **self._headers,
91
+ }
92
+ if self._secret:
93
+ sig = hmac.new(self._secret.encode("utf-8"), data, hashlib.sha256).hexdigest()
94
+ req_headers["X-Schemawatch-Signature"] = f"sha256={sig}"
95
+
96
+ self.last_status_code = None
97
+ self.last_response_body = None
98
+ self.last_attempts = 0
99
+
100
+ for attempt in range(1, self._max_retries + 2):
101
+ self.last_attempts = attempt
102
+ try:
103
+ req = urllib.request.Request(
104
+ self._url,
105
+ data=data,
106
+ headers=req_headers,
107
+ method="POST",
108
+ )
109
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp: # nosec B310
110
+ self.last_status_code = resp.status
111
+ self.last_response_body = resp.read().decode("utf-8", errors="replace")
112
+ if 200 <= resp.status < 300:
113
+ return True
114
+ except urllib.error.HTTPError as http_exc:
115
+ self.last_status_code = http_exc.code
116
+ try:
117
+ self.last_response_body = http_exc.read().decode("utf-8", errors="replace")
118
+ except Exception:
119
+ self.last_response_body = None
120
+ should_retry, retry_after = should_retry_error(http_exc)
121
+ logger.warning(
122
+ "schemawatch: webhook delivery failed (attempt %d/%d): HTTP %d",
123
+ attempt,
124
+ self._max_retries + 1,
125
+ http_exc.code,
126
+ )
127
+ if not should_retry:
128
+ return False
129
+
130
+ if attempt <= self._max_retries:
131
+ delay = (
132
+ retry_after
133
+ if retry_after is not None
134
+ else self._backoff * (2 ** (attempt - 1))
135
+ )
136
+ self._sleep_fn(delay)
137
+ except Exception as exc:
138
+ should_retry, retry_after = should_retry_error(exc)
139
+ # Privacy rule: NEVER log the webhook URL
140
+ logger.warning(
141
+ "schemawatch: webhook delivery failed (attempt %d/%d): %s",
142
+ attempt,
143
+ self._max_retries + 1,
144
+ type(exc).__name__,
145
+ )
146
+ if not should_retry:
147
+ return False
148
+
149
+ if attempt <= self._max_retries:
150
+ delay = (
151
+ retry_after
152
+ if retry_after is not None
153
+ else self._backoff * (2 ** (attempt - 1))
154
+ )
155
+ self._sleep_fn(delay)
156
+
157
+ logger.error(
158
+ "schemawatch: webhook alert delivery exhausted all retries for %s %s",
159
+ drift_event.vendor,
160
+ drift_event.endpoint,
161
+ )
162
+ return False