schemawatch-core 0.1.0__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.
- schemawatch_core-0.1.0/.gitignore +62 -0
- schemawatch_core-0.1.0/LICENSE +21 -0
- schemawatch_core-0.1.0/PKG-INFO +44 -0
- schemawatch_core-0.1.0/README.md +15 -0
- schemawatch_core-0.1.0/pyproject.toml +52 -0
- schemawatch_core-0.1.0/schemawatch/__init__.py +71 -0
- schemawatch_core-0.1.0/schemawatch/alerts/__init__.py +41 -0
- schemawatch_core-0.1.0/schemawatch/alerts/retry.py +46 -0
- schemawatch_core-0.1.0/schemawatch/alerts/slack.py +202 -0
- schemawatch_core-0.1.0/schemawatch/alerts/webhook.py +162 -0
- schemawatch_core-0.1.0/schemawatch/diff.py +247 -0
- schemawatch_core-0.1.0/schemawatch/infer.py +251 -0
- schemawatch_core-0.1.0/schemawatch/integrations/__init__.py +1 -0
- schemawatch_core-0.1.0/schemawatch/integrations/httpx_hook.py +235 -0
- schemawatch_core-0.1.0/schemawatch/py.typed +1 -0
- schemawatch_core-0.1.0/schemawatch/redact.py +243 -0
- schemawatch_core-0.1.0/schemawatch/severity.py +64 -0
- schemawatch_core-0.1.0/schemawatch/store.py +152 -0
- schemawatch_core-0.1.0/schemawatch/watchdog.py +148 -0
- schemawatch_core-0.1.0/tests/__init__.py +1 -0
- schemawatch_core-0.1.0/tests/test_alert_integration.py +127 -0
- schemawatch_core-0.1.0/tests/test_alerts.py +259 -0
- schemawatch_core-0.1.0/tests/test_diff.py +292 -0
- schemawatch_core-0.1.0/tests/test_infer.py +146 -0
- schemawatch_core-0.1.0/tests/test_integrations.py +355 -0
- schemawatch_core-0.1.0/tests/test_lazy_import.py +49 -0
- schemawatch_core-0.1.0/tests/test_mock_alerts.py +441 -0
- schemawatch_core-0.1.0/tests/test_privacy.py +189 -0
- schemawatch_core-0.1.0/tests/test_property.py +67 -0
- schemawatch_core-0.1.0/tests/test_redact.py +121 -0
- schemawatch_core-0.1.0/tests/test_severity.py +51 -0
- schemawatch_core-0.1.0/tests/test_store.py +82 -0
- schemawatch_core-0.1.0/tests/test_watchdog.py +123 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
/lib/
|
|
14
|
+
/lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
share/python-wheels/
|
|
20
|
+
*.egg-info/
|
|
21
|
+
.installed.cfg
|
|
22
|
+
*.egg
|
|
23
|
+
|
|
24
|
+
# Virtual environments
|
|
25
|
+
.venv/
|
|
26
|
+
venv/
|
|
27
|
+
ENV/
|
|
28
|
+
env/
|
|
29
|
+
|
|
30
|
+
# Testing & Coverage
|
|
31
|
+
.coverage
|
|
32
|
+
.coverage.*
|
|
33
|
+
coverage/
|
|
34
|
+
htmlcov/
|
|
35
|
+
.pytest_cache/
|
|
36
|
+
.hypothesis/
|
|
37
|
+
|
|
38
|
+
# Type checking & Linters
|
|
39
|
+
.mypy_cache/
|
|
40
|
+
.ruff_cache/
|
|
41
|
+
|
|
42
|
+
# Environment & Secrets
|
|
43
|
+
.env*
|
|
44
|
+
!.env.example
|
|
45
|
+
|
|
46
|
+
# Databases & Runtime state
|
|
47
|
+
*.db
|
|
48
|
+
*.sqlite
|
|
49
|
+
*.sqlite3
|
|
50
|
+
|
|
51
|
+
# Frontend & Dependencies
|
|
52
|
+
node_modules/
|
|
53
|
+
dist/
|
|
54
|
+
|
|
55
|
+
# OS / IDE
|
|
56
|
+
.DS_Store
|
|
57
|
+
Thumbs.db
|
|
58
|
+
*.swp
|
|
59
|
+
*.swo
|
|
60
|
+
.vscode/
|
|
61
|
+
.idea/
|
|
62
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 schemawatch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: schemawatch-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mohd-obaidullah/schemawatch
|
|
6
|
+
Project-URL: Source, https://github.com/Mohd-obaidullah/schemawatch
|
|
7
|
+
Project-URL: Issues, https://github.com/Mohd-obaidullah/schemawatch/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Mohd-obaidullah/schemawatch/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Mohd Obaidullah
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,contract-testing,drift,monitoring,schema
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: httpx
|
|
25
|
+
Requires-Dist: httpx>=0.27.0; extra == 'httpx'
|
|
26
|
+
Provides-Extra: redis
|
|
27
|
+
Requires-Dist: redis>=5.0.0; extra == 'redis'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# schemawatch core
|
|
31
|
+
|
|
32
|
+
Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install schemawatch
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Optional extras:
|
|
41
|
+
```bash
|
|
42
|
+
pip install "schemawatch[redis]" # Redis-backed baseline and dedupe store
|
|
43
|
+
pip install "schemawatch[httpx]" # httpx integration hook
|
|
44
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# schemawatch core
|
|
2
|
+
|
|
3
|
+
Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install schemawatch
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Optional extras:
|
|
12
|
+
```bash
|
|
13
|
+
pip install "schemawatch[redis]" # Redis-backed baseline and dedupe store
|
|
14
|
+
pip install "schemawatch[httpx]" # httpx integration hook
|
|
15
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "schemawatch-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pure Python engine for schema inference, diffing, PII redaction, drift severity classification, and sampling watchdog"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Mohd Obaidullah" }]
|
|
14
|
+
keywords = ["api", "schema", "drift", "contract-testing", "monitoring"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Testing",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = []
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/Mohd-obaidullah/schemawatch"
|
|
31
|
+
Source = "https://github.com/Mohd-obaidullah/schemawatch"
|
|
32
|
+
Issues = "https://github.com/Mohd-obaidullah/schemawatch/issues"
|
|
33
|
+
Changelog = "https://github.com/Mohd-obaidullah/schemawatch/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
redis = ["redis>=5.0.0"]
|
|
37
|
+
httpx = ["httpx>=0.27.0"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["schemawatch"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
strict = true
|
|
45
|
+
warn_return_any = true
|
|
46
|
+
warn_unused_configs = true
|
|
47
|
+
disallow_untyped_defs = true
|
|
48
|
+
disallow_incomplete_defs = true
|
|
49
|
+
check_untyped_defs = true
|
|
50
|
+
no_implicit_optional = true
|
|
51
|
+
warn_redundant_casts = true
|
|
52
|
+
warn_unused_ignores = true
|
|
@@ -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
|