streamgine 1.0.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.
- streamgine/__init__.py +5 -0
- streamgine/diff/__init__.py +40 -0
- streamgine/diff/client.py +188 -0
- streamgine/diff/constants.py +5 -0
- streamgine/diff/credentials.py +153 -0
- streamgine/diff/exceptions.py +9 -0
- streamgine/diff/models.py +31 -0
- streamgine/diff/webhooks.py +83 -0
- streamgine-1.0.0.dist-info/METADATA +323 -0
- streamgine-1.0.0.dist-info/RECORD +11 -0
- streamgine-1.0.0.dist-info/WHEEL +4 -0
streamgine/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Diff by Streamgine — register webhooks and verify shared-secret events."""
|
|
2
|
+
|
|
3
|
+
from streamgine.diff.client import DiffClient
|
|
4
|
+
from streamgine.diff.constants import TEST_ROUTING_FILING_TYPE, TEST_ROUTING_STATE, TEST_TOPIC
|
|
5
|
+
from streamgine.diff.credentials import (
|
|
6
|
+
API_KEY_ENV,
|
|
7
|
+
API_URL_ENV,
|
|
8
|
+
CREDENTIALS_FILE_ENV,
|
|
9
|
+
CUSTOMER_ID_ENV,
|
|
10
|
+
PROVIDER_API_KEY_ENV,
|
|
11
|
+
SIGNING_SECRET_ENV,
|
|
12
|
+
ClientConfig,
|
|
13
|
+
configure,
|
|
14
|
+
get_config,
|
|
15
|
+
reset_config, # importable for tests; omitted from __all__
|
|
16
|
+
)
|
|
17
|
+
from streamgine.diff.exceptions import DiffError, WebhookVerificationError
|
|
18
|
+
from streamgine.diff.models import WebhookEvent
|
|
19
|
+
from streamgine.diff.webhooks import SIGNATURE_HEADER, verify_webhook
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DiffClient",
|
|
23
|
+
"DiffError",
|
|
24
|
+
"ClientConfig",
|
|
25
|
+
"configure",
|
|
26
|
+
"get_config",
|
|
27
|
+
"CREDENTIALS_FILE_ENV",
|
|
28
|
+
"API_URL_ENV",
|
|
29
|
+
"API_KEY_ENV",
|
|
30
|
+
"SIGNING_SECRET_ENV",
|
|
31
|
+
"PROVIDER_API_KEY_ENV",
|
|
32
|
+
"CUSTOMER_ID_ENV",
|
|
33
|
+
"WebhookVerificationError",
|
|
34
|
+
"WebhookEvent",
|
|
35
|
+
"TEST_ROUTING_STATE",
|
|
36
|
+
"TEST_ROUTING_FILING_TYPE",
|
|
37
|
+
"TEST_TOPIC",
|
|
38
|
+
"SIGNATURE_HEADER",
|
|
39
|
+
"verify_webhook",
|
|
40
|
+
]
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""HTTP client for the Diff by Streamgine API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from streamgine.diff.constants import TEST_ROUTING_FILING_TYPE, TEST_ROUTING_STATE
|
|
11
|
+
from streamgine.diff.credentials import API_KEY_ENV, get_config
|
|
12
|
+
from streamgine.diff.exceptions import DiffError
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
|
|
16
|
+
_AUTH_SETUP_HINT = "Call configure() once at startup (JSON file or DIFF_* env vars)."
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DiffClient:
|
|
20
|
+
"""
|
|
21
|
+
Register agents and inspect entity snapshots on Diff by Streamgine.
|
|
22
|
+
|
|
23
|
+
Credentials come from a single :func:`configure` call — not constructor args.
|
|
24
|
+
|
|
25
|
+
Use one ``DiffClient`` per thread for concurrent requests. Sharing a single
|
|
26
|
+
instance across threads is not safe (httpx ``Client`` is not thread-safe for
|
|
27
|
+
concurrent use).
|
|
28
|
+
|
|
29
|
+
Example::
|
|
30
|
+
|
|
31
|
+
from streamgine import diff
|
|
32
|
+
|
|
33
|
+
diff.configure("diff-streamgine-credentials.json") # or source diff-streamgine-env.sh then configure()
|
|
34
|
+
client = diff.DiffClient()
|
|
35
|
+
client.register_test(callback_url="https://example.com/webhooks/diff")
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
42
|
+
verify_tls: bool = True,
|
|
43
|
+
headers: Mapping[str, str] | None = None,
|
|
44
|
+
transport: httpx.BaseTransport | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
config = get_config()
|
|
47
|
+
self._base_url = config.api_url
|
|
48
|
+
self._provider_api_key = config.provider_api_key
|
|
49
|
+
merged_headers: dict[str, str] = {
|
|
50
|
+
"Accept": "application/json",
|
|
51
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
52
|
+
**dict(headers or {}),
|
|
53
|
+
}
|
|
54
|
+
client_kwargs: dict[str, Any] = {
|
|
55
|
+
"base_url": self._base_url,
|
|
56
|
+
"timeout": timeout,
|
|
57
|
+
"verify": verify_tls,
|
|
58
|
+
"headers": merged_headers,
|
|
59
|
+
}
|
|
60
|
+
if transport is not None:
|
|
61
|
+
client_kwargs["transport"] = transport
|
|
62
|
+
self._client = httpx.Client(**client_kwargs)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self._client.close()
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> DiffClient:
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *args: object) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def health(self) -> dict[str, Any]:
|
|
74
|
+
"""GET /health — liveness check."""
|
|
75
|
+
return self._request("GET", "/health")
|
|
76
|
+
|
|
77
|
+
def register(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
callback_url: str,
|
|
81
|
+
match: Mapping[str, str] | None = None,
|
|
82
|
+
actions: list[str] | None = None,
|
|
83
|
+
) -> dict[str, Any]:
|
|
84
|
+
"""
|
|
85
|
+
POST /api/subscriptions — subscribe this account with field filters.
|
|
86
|
+
|
|
87
|
+
Prefer ``match`` (equality AND). Examples::
|
|
88
|
+
|
|
89
|
+
match={"state": "CA"}
|
|
90
|
+
match={"state": "CA", "principal_city": "San Francisco"}
|
|
91
|
+
match={"principal_city": "San Francisco"}
|
|
92
|
+
|
|
93
|
+
Optional ``actions`` (e.g. ``["INSERT"]``) refine after the match.
|
|
94
|
+
|
|
95
|
+
Re-registering replaces filters and callback URL for the same account.
|
|
96
|
+
"""
|
|
97
|
+
body: dict[str, Any] = {"callbackUrl": callback_url}
|
|
98
|
+
if match is not None:
|
|
99
|
+
body["match"] = dict(match)
|
|
100
|
+
if actions is not None:
|
|
101
|
+
body["actions"] = actions
|
|
102
|
+
return self._request(
|
|
103
|
+
"POST",
|
|
104
|
+
"/api/subscriptions",
|
|
105
|
+
json=body,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def register_test(self, *, callback_url: str) -> dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Subscribe to developer test heartbeats on topic ``diff-event.test``.
|
|
111
|
+
|
|
112
|
+
Convenience wrapper for ``register()`` with test heartbeat filters —
|
|
113
|
+
same path as production subscriptions.
|
|
114
|
+
"""
|
|
115
|
+
return self.register(
|
|
116
|
+
match={
|
|
117
|
+
"state": TEST_ROUTING_STATE,
|
|
118
|
+
"filing_type": TEST_ROUTING_FILING_TYPE,
|
|
119
|
+
},
|
|
120
|
+
callback_url=callback_url,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def search(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
query: Mapping[str, Any],
|
|
127
|
+
from_: int = 0,
|
|
128
|
+
size: int = 10,
|
|
129
|
+
sort: Any | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""
|
|
132
|
+
POST /api/search — full-story company search (current snapshots).
|
|
133
|
+
|
|
134
|
+
``query`` is a search query object (same shape as Elasticsearch Query DSL
|
|
135
|
+
``query``). Results are current company bodies in snake_case — not live
|
|
136
|
+
webhook events. Max ``size`` is 100.
|
|
137
|
+
"""
|
|
138
|
+
body: dict[str, Any] = {
|
|
139
|
+
"query": dict(query),
|
|
140
|
+
"from": from_,
|
|
141
|
+
"size": size,
|
|
142
|
+
}
|
|
143
|
+
if sort is not None:
|
|
144
|
+
body["sort"] = sort
|
|
145
|
+
return self._request("POST", "/api/search", json=body)
|
|
146
|
+
|
|
147
|
+
def get_state(self, entity_id: str) -> dict[str, Any]:
|
|
148
|
+
"""GET /api/provider/entities/:id — read the current entity snapshot (provider key)."""
|
|
149
|
+
headers: dict[str, str] = {}
|
|
150
|
+
if self._provider_api_key:
|
|
151
|
+
headers["Authorization"] = f"Bearer {self._provider_api_key}"
|
|
152
|
+
encoded_id = quote(entity_id, safe="")
|
|
153
|
+
return self._request(
|
|
154
|
+
"GET",
|
|
155
|
+
f"/api/provider/entities/{encoded_id}",
|
|
156
|
+
headers=headers,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
160
|
+
try:
|
|
161
|
+
response = self._client.request(method, path, **kwargs)
|
|
162
|
+
response.raise_for_status()
|
|
163
|
+
except httpx.HTTPStatusError as err:
|
|
164
|
+
detail = err.response.text.strip() or err.response.reason_phrase
|
|
165
|
+
message = f"{method} {path} failed ({err.response.status_code}): {detail}"
|
|
166
|
+
if err.response.status_code == 401:
|
|
167
|
+
message += (
|
|
168
|
+
f" — API key rejected. Regenerate credentials on /login or check "
|
|
169
|
+
f"{API_KEY_ENV}. {_AUTH_SETUP_HINT}"
|
|
170
|
+
)
|
|
171
|
+
raise DiffError(message) from err
|
|
172
|
+
except httpx.HTTPError as err:
|
|
173
|
+
message = f"{method} {path} failed: {err}"
|
|
174
|
+
err_text = str(err)
|
|
175
|
+
if "WRONG_VERSION_NUMBER" in err_text or "SSL:" in err_text:
|
|
176
|
+
message += (
|
|
177
|
+
" — TLS handshake failed, often because DIFF_API_URL uses https:// "
|
|
178
|
+
"against an http-only server."
|
|
179
|
+
)
|
|
180
|
+
raise DiffError(message) from err
|
|
181
|
+
|
|
182
|
+
if not response.content:
|
|
183
|
+
return {}
|
|
184
|
+
|
|
185
|
+
data = response.json()
|
|
186
|
+
if not isinstance(data, dict):
|
|
187
|
+
raise DiffError(f"{method} {path} returned unexpected JSON")
|
|
188
|
+
return data
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Single-point Diff credentials setup — JSON file or DIFF_* env, not both."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
SUPPORTED_CREDENTIALS_VERSION = 1
|
|
12
|
+
|
|
13
|
+
CREDENTIALS_FILE_ENV = "DIFF_CREDENTIALS_FILE"
|
|
14
|
+
API_URL_ENV = "DIFF_API_URL"
|
|
15
|
+
API_KEY_ENV = "DIFF_API_KEY"
|
|
16
|
+
SIGNING_SECRET_ENV = "DIFF_SIGNING_SECRET"
|
|
17
|
+
PROVIDER_API_KEY_ENV = "DIFF_PROVIDER_API_KEY"
|
|
18
|
+
CUSTOMER_ID_ENV = "DIFF_CUSTOMER_ID"
|
|
19
|
+
|
|
20
|
+
# Customer-facing download page — full URL, never bare /account
|
|
21
|
+
ACCOUNT_CREDENTIALS_URL = "https://diff.streamgine.com/account"
|
|
22
|
+
CREDENTIALS_DOWNLOAD_HINT = (
|
|
23
|
+
f"diff-streamgine-credentials.json from {ACCOUNT_CREDENTIALS_URL}"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_REQUIRED_ENV = (API_URL_ENV, API_KEY_ENV, SIGNING_SECRET_ENV)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ClientConfig:
|
|
31
|
+
"""Resolved Diff credentials (read-only after configure)."""
|
|
32
|
+
|
|
33
|
+
api_url: str
|
|
34
|
+
api_key: str
|
|
35
|
+
signing_secret: str
|
|
36
|
+
provider_api_key: str | None = None
|
|
37
|
+
customer_id: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_config: ClientConfig | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure(credentials: str | Path | None = None) -> ClientConfig:
|
|
44
|
+
"""
|
|
45
|
+
Load credentials once at process startup from a JSON file **or** env vars.
|
|
46
|
+
|
|
47
|
+
When called with no path argument, **environment variables must already be
|
|
48
|
+
set** before this call: ``DIFF_API_URL``, ``DIFF_API_KEY``, and
|
|
49
|
+
``DIFF_SIGNING_SECRET`` (e.g. ``source diff-streamgine-env.sh`` from
|
|
50
|
+
https://diff.streamgine.com/account). Alternatively set
|
|
51
|
+
``DIFF_CREDENTIALS_FILE`` to a checkout JSON path and call ``configure()``
|
|
52
|
+
with no args.
|
|
53
|
+
|
|
54
|
+
Use **one** source only:
|
|
55
|
+
|
|
56
|
+
- ``configure("diff-streamgine-credentials.json")`` — checkout JSON file
|
|
57
|
+
- ``configure()`` — reads from ``DIFF_*`` env (must be exported first via
|
|
58
|
+
``source diff-streamgine-env.sh``)
|
|
59
|
+
|
|
60
|
+
Call again only to replace credentials (e.g. tests). Every ``DiffClient()``
|
|
61
|
+
and ``verify_webhook()`` reads from this config.
|
|
62
|
+
"""
|
|
63
|
+
global _config
|
|
64
|
+
|
|
65
|
+
path = credentials
|
|
66
|
+
if path is None:
|
|
67
|
+
path = os.environ.get(CREDENTIALS_FILE_ENV)
|
|
68
|
+
|
|
69
|
+
if path:
|
|
70
|
+
_config = _config_from_file(Path(path))
|
|
71
|
+
else:
|
|
72
|
+
_config = _config_from_env()
|
|
73
|
+
|
|
74
|
+
return _config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_config() -> ClientConfig:
|
|
78
|
+
"""Return configured credentials. Raises if ``configure()`` was not called."""
|
|
79
|
+
if _config is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
"Diff credentials not configured — call configure() once at startup "
|
|
82
|
+
f"(JSON path or {', '.join(_REQUIRED_ENV)} env vars)."
|
|
83
|
+
)
|
|
84
|
+
return _config
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def reset_config() -> None:
|
|
88
|
+
"""Clear configured credentials (tests only)."""
|
|
89
|
+
global _config
|
|
90
|
+
_config = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_credentials_file(path: Path) -> dict[str, Any]:
|
|
94
|
+
try:
|
|
95
|
+
with path.open(encoding="utf-8") as handle:
|
|
96
|
+
data = json.load(handle)
|
|
97
|
+
except FileNotFoundError as err:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"credentials file not found: {path} — download {CREDENTIALS_DOWNLOAD_HINT}"
|
|
100
|
+
) from err
|
|
101
|
+
except OSError as err:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"could not read credentials file {path}: {err} — download "
|
|
104
|
+
f"{CREDENTIALS_DOWNLOAD_HINT}"
|
|
105
|
+
) from err
|
|
106
|
+
except json.JSONDecodeError as err:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"credentials file is not valid JSON ({path}): {err.msg} — "
|
|
109
|
+
f"re-download {CREDENTIALS_DOWNLOAD_HINT}"
|
|
110
|
+
) from err
|
|
111
|
+
|
|
112
|
+
if not isinstance(data, dict):
|
|
113
|
+
raise ValueError("credentials file must contain a JSON object")
|
|
114
|
+
|
|
115
|
+
version = data.get("version")
|
|
116
|
+
if version != SUPPORTED_CREDENTIALS_VERSION:
|
|
117
|
+
raise ValueError(f"unsupported credentials file version: {version!r}")
|
|
118
|
+
|
|
119
|
+
for key in ("customer_id", "api_key", "signing_secret", "api_url"):
|
|
120
|
+
if not data.get(key):
|
|
121
|
+
raise ValueError(f"credentials file missing required field: {key}")
|
|
122
|
+
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _config_from_file(path: Path) -> ClientConfig:
|
|
127
|
+
raw = _load_credentials_file(path)
|
|
128
|
+
env = raw.get("env") if isinstance(raw.get("env"), dict) else {}
|
|
129
|
+
api_url = str(raw["api_url"]).rstrip("/")
|
|
130
|
+
return ClientConfig(
|
|
131
|
+
api_url=api_url,
|
|
132
|
+
api_key=str(raw["api_key"]),
|
|
133
|
+
signing_secret=str(raw["signing_secret"]),
|
|
134
|
+
provider_api_key=env.get(PROVIDER_API_KEY_ENV) or None,
|
|
135
|
+
customer_id=str(raw["customer_id"]) if raw.get("customer_id") else None,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _config_from_env() -> ClientConfig:
|
|
140
|
+
missing = [name for name in _REQUIRED_ENV if not os.environ.get(name)]
|
|
141
|
+
if missing:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"missing required env var(s): {', '.join(missing)} — "
|
|
144
|
+
f"export them or call configure('diff-streamgine-credentials.json')"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return ClientConfig(
|
|
148
|
+
api_url=os.environ[API_URL_ENV].rstrip("/"),
|
|
149
|
+
api_key=os.environ[API_KEY_ENV],
|
|
150
|
+
signing_secret=os.environ[SIGNING_SECRET_ENV],
|
|
151
|
+
provider_api_key=os.environ.get(PROVIDER_API_KEY_ENV) or None,
|
|
152
|
+
customer_id=os.environ.get(CUSTOMER_ID_ENV) or None,
|
|
153
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Typed webhook payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Literal, Mapping
|
|
7
|
+
|
|
8
|
+
CustomerAction = Literal["INSERT", "UPDATE", "DELETE"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class WebhookEvent:
|
|
13
|
+
"""Verified customer webhook from Diff by Streamgine."""
|
|
14
|
+
|
|
15
|
+
action: CustomerAction
|
|
16
|
+
entity_id: str | None = None
|
|
17
|
+
after: Mapping[str, str] | None = None
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_dict(cls, data: Mapping[str, Any]) -> WebhookEvent:
|
|
21
|
+
after = data.get("after")
|
|
22
|
+
entity_id = data.get("entity_id")
|
|
23
|
+
return cls(
|
|
24
|
+
action=data["action"],
|
|
25
|
+
entity_id=str(entity_id) if entity_id is not None else None,
|
|
26
|
+
after=dict(after) if isinstance(after, Mapping) else None,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_test_heartbeat(self) -> bool:
|
|
31
|
+
return self.after == {"test": "success"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""HMAC verification for inbound Diff by Streamgine webhooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from streamgine.diff.credentials import get_config
|
|
11
|
+
from streamgine.diff.exceptions import WebhookVerificationError
|
|
12
|
+
from streamgine.diff.models import WebhookEvent
|
|
13
|
+
|
|
14
|
+
SIGNATURE_HEADER = "X-Diff-Signature"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _expected_signature(raw_body: bytes, signing_secret: str) -> str:
|
|
18
|
+
return hmac.new(
|
|
19
|
+
signing_secret.encode("utf-8"),
|
|
20
|
+
raw_body,
|
|
21
|
+
hashlib.sha256,
|
|
22
|
+
).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize_signature_header(header: str | None) -> str | None:
|
|
26
|
+
if not header:
|
|
27
|
+
return None
|
|
28
|
+
value = header.strip()
|
|
29
|
+
if value.lower().startswith("sha256="):
|
|
30
|
+
return value[7:]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verify_webhook(
|
|
35
|
+
raw_body: bytes,
|
|
36
|
+
signature_header: str | None,
|
|
37
|
+
*,
|
|
38
|
+
signing_secret: str | None = None,
|
|
39
|
+
) -> WebhookEvent:
|
|
40
|
+
"""
|
|
41
|
+
Verify ``X-Diff-Signature`` over the exact raw JSON bytes, then parse the event.
|
|
42
|
+
|
|
43
|
+
Uses the signing secret from :func:`configure` unless ``signing_secret`` is
|
|
44
|
+
passed explicitly (rare — prefer a single ``configure()`` at startup).
|
|
45
|
+
|
|
46
|
+
Must run on the unmodified request body (before JSON parsing) so the HMAC matches
|
|
47
|
+
what the Diff by Streamgine worker signed.
|
|
48
|
+
"""
|
|
49
|
+
secret = signing_secret if signing_secret is not None else get_config().signing_secret
|
|
50
|
+
if not secret:
|
|
51
|
+
raise WebhookVerificationError("signing_secret is required — call configure() first")
|
|
52
|
+
|
|
53
|
+
received = _normalize_signature_header(signature_header)
|
|
54
|
+
if not received:
|
|
55
|
+
raise WebhookVerificationError("missing X-Diff-Signature header")
|
|
56
|
+
|
|
57
|
+
expected = _expected_signature(raw_body, secret)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
valid = hmac.compare_digest(expected, received)
|
|
61
|
+
except TypeError as err:
|
|
62
|
+
raise WebhookVerificationError("invalid signature encoding") from err
|
|
63
|
+
|
|
64
|
+
if not valid:
|
|
65
|
+
raise WebhookVerificationError("invalid webhook signature")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
payload: dict[str, Any] = json.loads(raw_body)
|
|
69
|
+
except json.JSONDecodeError as err:
|
|
70
|
+
raise WebhookVerificationError("invalid JSON body") from err
|
|
71
|
+
|
|
72
|
+
if "action" not in payload:
|
|
73
|
+
raise WebhookVerificationError("missing webhook fields: action")
|
|
74
|
+
|
|
75
|
+
if payload.get("after") == {"test": "success"}:
|
|
76
|
+
pass
|
|
77
|
+
elif "entity_id" not in payload:
|
|
78
|
+
raise WebhookVerificationError("missing webhook fields: entity_id")
|
|
79
|
+
|
|
80
|
+
if payload["action"] not in ("INSERT", "UPDATE", "DELETE"):
|
|
81
|
+
raise WebhookVerificationError("invalid action")
|
|
82
|
+
|
|
83
|
+
return WebhookEvent.from_dict(payload)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: streamgine
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Streamgine Python SDK — Diff by Streamgine webhooks client and future modules.
|
|
5
|
+
Project-URL: Homepage, https://streamgine.com
|
|
6
|
+
Project-URL: Documentation, https://diff.streamgine.com/docs.md
|
|
7
|
+
Project-URL: Repository, https://github.com/scurtutech/diff-events-service
|
|
8
|
+
Project-URL: Issues, https://github.com/scurtutech/diff-events-service/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/scurtutech/diff-events-service/releases
|
|
10
|
+
Author-email: Streamgine <hello@streamgine.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
Keywords: agents,compliance,corporate-standing,diff,streamgine,webhooks
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
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 :: Internet :: WWW/HTTP
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx>=0.27.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# streamgine — Python SDK
|
|
30
|
+
|
|
31
|
+
**Diff by Streamgine** lives under the `diff` module. Install once; add more Streamgine modules (training data, etc.) under the same package later.
|
|
32
|
+
|
|
33
|
+
This directory is the **customer package** published to PyPI. Repo-only load tests and dev agents live in [`tools/python/`](../../tools/python/README.md) — not shipped to customers.
|
|
34
|
+
|
|
35
|
+
**Today:** one wheel `streamgine` = Diff (`from streamgine import diff`). **Next products** (e.g. training-data) get their own wheels (`streamgine-diff`, `streamgine-training`, …) under the same `streamgine` import namespace — see [PUBLISHING.md](PUBLISHING.md) and `.cursor/rules/python-client.mdc`. Do not grow a fat single package.
|
|
36
|
+
|
|
37
|
+
**Product:** [diff.streamgine.com](https://diff.streamgine.com) · **Docs:** [docs.md](https://diff.streamgine.com/docs.md) · **California:** [coverage](https://diff.streamgine.com/california) · **Use cases:** [risk monitoring](https://diff.streamgine.com/use-cases/risk-monitoring), [AI agents](https://diff.streamgine.com/use-cases/ai-agents) · **Repo:** [github.com/scurtutech/diff-events-service](https://github.com/scurtutech/diff-events-service)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from streamgine import diff
|
|
41
|
+
|
|
42
|
+
# Once at startup — pick ONE download from /account:
|
|
43
|
+
diff.configure("diff-streamgine-credentials.json")
|
|
44
|
+
# OR: source diff-streamgine-env.sh then diff.configure()
|
|
45
|
+
client = diff.DiffClient()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Install from PyPI:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
python3 -m pip install streamgine
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or from this repo:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd clients/python
|
|
58
|
+
python3 -m venv .venv && source .venv/bin/activate
|
|
59
|
+
python -m pip install .
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires Python 3.10+. One dependency: [httpx](https://www.python-httpx.org/) (TLS verification enabled by default).
|
|
63
|
+
|
|
64
|
+
## Credentials (one setup point)
|
|
65
|
+
|
|
66
|
+
Call `diff.configure()` **once** at process startup. Every `DiffClient()` and `verify_webhook()` reads from that config.
|
|
67
|
+
|
|
68
|
+
Download from `/account` or checkout — filenames are fixed:
|
|
69
|
+
|
|
70
|
+
| Download | Then |
|
|
71
|
+
| ---------------------------------- | ------------------------------------------------------- |
|
|
72
|
+
| `diff-streamgine-credentials.json` | `diff.configure("diff-streamgine-credentials.json")` |
|
|
73
|
+
| `diff-streamgine-env.sh` | `source diff-streamgine-env.sh` then `diff.configure()` |
|
|
74
|
+
|
|
75
|
+
**Option A — credentials JSON:**
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
diff.configure("diff-streamgine-credentials.json")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Option B — env script:**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
source diff-streamgine-env.sh
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
diff.configure()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Do **not** pass API keys or signing secrets to `DiffClient()` or `verify_webhook()` in normal use.
|
|
92
|
+
|
|
93
|
+
## Zero-setup agent (copy-paste)
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
python3 -m venv .venv
|
|
97
|
+
source .venv/bin/activate
|
|
98
|
+
python -m pip install -e . fastapi uvicorn
|
|
99
|
+
# Customer path: download from /account, then ONE of:
|
|
100
|
+
# source diff-streamgine-env.sh
|
|
101
|
+
# # or keep diff-streamgine-credentials.json next to the process
|
|
102
|
+
# Local repo seed (AUTH_ENABLED): npm run seed:dev-customer, then export printed keys
|
|
103
|
+
source diff-streamgine-env.sh # or configure("diff-streamgine-credentials.json") in code
|
|
104
|
+
python -m uvicorn examples.agent:app --port 8000
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Runnable source: [`examples/agent.py`](examples/agent.py). Calls `register_test()` on startup; within ~1s you should see `after={'test': 'success'}` on topic `diff-event.test`.
|
|
108
|
+
|
|
109
|
+
**Serverless (AWS Lambda):** [`examples/serverless/`](examples/serverless/README.md) — deploy `handler.py`, run `register.py` once, receive test heartbeats in CloudWatch.
|
|
110
|
+
|
|
111
|
+
**Agent journey (production filters):** [`examples/agent_journey.py`](examples/agent_journey.py).
|
|
112
|
+
|
|
113
|
+
Docker worker reaching a host agent:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
export DIFF_CALLBACK_URL=http://host.docker.internal:8000/webhooks/diff
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Test heartbeats (start here)
|
|
120
|
+
|
|
121
|
+
Before wiring real registry events, confirm your agent receives signed webhooks:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from streamgine import diff
|
|
125
|
+
|
|
126
|
+
diff.configure() # after: source diff-streamgine-env.sh OR configure("diff-streamgine-credentials.json")
|
|
127
|
+
client = diff.DiffClient()
|
|
128
|
+
client.register_test(callback_url="https://your-agent.example/webhooks/diff")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Filter on **`event.is_test_heartbeat`** (or `after == {"test": "success"}`). The service sends this every second:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"action": "INSERT",
|
|
136
|
+
"after": { "test": "success" }
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
No `entity_id` on test events.
|
|
141
|
+
|
|
142
|
+
## Quick start (production registry events)
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from streamgine import diff
|
|
146
|
+
|
|
147
|
+
diff.configure()
|
|
148
|
+
client = diff.DiffClient()
|
|
149
|
+
client.register(
|
|
150
|
+
match={
|
|
151
|
+
"state": "CA",
|
|
152
|
+
"principal_city": "San Francisco",
|
|
153
|
+
},
|
|
154
|
+
actions=["INSERT"],
|
|
155
|
+
callback_url="https://your-agent.example/webhooks/diff",
|
|
156
|
+
)
|
|
157
|
+
client.close()
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`INSERT` = newly added company, `UPDATE` = changed, `DELETE` = removed. Omit `actions` to receive every matching state/city event.
|
|
161
|
+
|
|
162
|
+
### Search current companies (full story)
|
|
163
|
+
|
|
164
|
+
Webhooks deliver changes. Search returns **current** snapshots:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from streamgine import diff
|
|
168
|
+
|
|
169
|
+
diff.configure()
|
|
170
|
+
client = diff.DiffClient()
|
|
171
|
+
result = client.search(
|
|
172
|
+
query={"match": {"principalCity": "San Francisco"}},
|
|
173
|
+
size=10,
|
|
174
|
+
)
|
|
175
|
+
for company in result["entities"]:
|
|
176
|
+
print(company.get("entity_name"), company.get("entity_number"))
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Agent journey: **Search → Watch → Qualify → Act → Stay current** (see [`/docs.md`](../../public/docs.md)).
|
|
180
|
+
|
|
181
|
+
### 2. Verify inbound webhooks (required)
|
|
182
|
+
|
|
183
|
+
Always verify **raw request bytes** before trusting JSON. The worker signs the exact JSON body with HMAC-SHA256.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from streamgine import diff
|
|
187
|
+
|
|
188
|
+
diff.configure()
|
|
189
|
+
|
|
190
|
+
def handle_webhook(raw_body: bytes, signature: str | None):
|
|
191
|
+
try:
|
|
192
|
+
event = diff.verify_webhook(raw_body, signature)
|
|
193
|
+
except diff.WebhookVerificationError:
|
|
194
|
+
return 401, {"error": "invalid signature"}
|
|
195
|
+
|
|
196
|
+
print(event.action, event.entity_id, event.after)
|
|
197
|
+
return 200, {"ok": True}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**FastAPI**
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
from fastapi import FastAPI, Header, Request, Response
|
|
204
|
+
|
|
205
|
+
from streamgine import diff
|
|
206
|
+
|
|
207
|
+
app = FastAPI()
|
|
208
|
+
|
|
209
|
+
@app.on_event("startup")
|
|
210
|
+
def setup() -> None:
|
|
211
|
+
diff.configure()
|
|
212
|
+
|
|
213
|
+
@app.post("/webhooks/diff")
|
|
214
|
+
async def diff_webhook(
|
|
215
|
+
request: Request,
|
|
216
|
+
response: Response,
|
|
217
|
+
x_diff_signature: str | None = Header(default=None, alias="X-Diff-Signature"),
|
|
218
|
+
):
|
|
219
|
+
raw = await request.body()
|
|
220
|
+
try:
|
|
221
|
+
event = diff.verify_webhook(raw, x_diff_signature)
|
|
222
|
+
except diff.WebhookVerificationError:
|
|
223
|
+
response.status_code = 401
|
|
224
|
+
return {"error": "invalid signature"}
|
|
225
|
+
|
|
226
|
+
# Your agent logic here
|
|
227
|
+
return {"ok": True, "entity_id": event.entity_id}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Flask**
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from flask import Flask, request
|
|
234
|
+
|
|
235
|
+
from streamgine import diff
|
|
236
|
+
|
|
237
|
+
app = Flask(__name__)
|
|
238
|
+
diff.configure()
|
|
239
|
+
|
|
240
|
+
@app.post("/webhooks/diff")
|
|
241
|
+
def webhook():
|
|
242
|
+
try:
|
|
243
|
+
event = diff.verify_webhook(
|
|
244
|
+
request.get_data(),
|
|
245
|
+
request.headers.get("X-Diff-Signature"),
|
|
246
|
+
)
|
|
247
|
+
except diff.WebhookVerificationError:
|
|
248
|
+
return {"error": "invalid signature"}, 401
|
|
249
|
+
|
|
250
|
+
return {"ok": True, "action": event.action}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Security
|
|
254
|
+
|
|
255
|
+
| Practice | How this client helps |
|
|
256
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------- |
|
|
257
|
+
| Verify every webhook | `diff.verify_webhook()` uses `hmac.compare_digest` (timing-safe) on the raw body |
|
|
258
|
+
| Match worker algorithm | Same as Node: `HMAC-SHA256(secret, raw_json_bytes)` → header `X-Diff-Signature: sha256=<hex>` |
|
|
259
|
+
| Parse JSON only after verify | Tampered bodies fail before `WebhookEvent` is built |
|
|
260
|
+
| HTTPS in production | `DiffClient(verify_tls=True)` (default); use `https://` callback URLs |
|
|
261
|
+
| Keep secrets out of code | One `configure()` from JSON or env — never scatter keys in constructors |
|
|
262
|
+
|
|
263
|
+
Never log the signing secret or skip signature verification in production.
|
|
264
|
+
|
|
265
|
+
### Credentials file (after checkout)
|
|
266
|
+
|
|
267
|
+
Download **`diff-streamgine-credentials.json`** (or **`diff-streamgine-env.sh`**) from `/account` / checkout. JSON format (version `1`):
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"version": 1,
|
|
272
|
+
"service": "diff.streamgine.com",
|
|
273
|
+
"customer_id": "cus_…",
|
|
274
|
+
"api_key": "de_live_…",
|
|
275
|
+
"signing_secret": "whsec_…",
|
|
276
|
+
"api_url": "https://diff.streamgine.com",
|
|
277
|
+
"env": {
|
|
278
|
+
"DIFF_API_KEY": "de_live_…",
|
|
279
|
+
"DIFF_SIGNING_SECRET": "whsec_…",
|
|
280
|
+
"DIFF_API_URL": "https://diff.streamgine.com"
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
from streamgine import diff
|
|
287
|
+
|
|
288
|
+
diff.configure("diff-streamgine-credentials.json")
|
|
289
|
+
client = diff.DiffClient()
|
|
290
|
+
# diff.verify_webhook(...) uses the same signing secret automatically
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Auth
|
|
294
|
+
|
|
295
|
+
| Env (via `configure()`) | Used for |
|
|
296
|
+
| ----------------------- | --------------------------------------------------------------- |
|
|
297
|
+
| `DIFF_API_KEY` | `register()` / `register_test()` (customer Bearer) |
|
|
298
|
+
| `DIFF_PROVIDER_API_KEY` | `get_state()` and provider ingest when provider auth is enabled |
|
|
299
|
+
|
|
300
|
+
Register responses are slim (`ok` / subscription fields only — no Redis index maps).
|
|
301
|
+
|
|
302
|
+
## API surface (`streamgine.diff`)
|
|
303
|
+
|
|
304
|
+
| Symbol | Role |
|
|
305
|
+
| --------------------------- | ---------------------------------------------------------- |
|
|
306
|
+
| `configure()` | Load credentials once (JSON path or `DIFF_*` env) |
|
|
307
|
+
| `get_config()` | Read configured `ClientConfig` |
|
|
308
|
+
| `DiffClient` | `register()`, `register_test()`, `health()`, `get_state()` |
|
|
309
|
+
| `verify_webhook()` | Verify signature + return `WebhookEvent` |
|
|
310
|
+
| `DIFF_*` env name constants | `API_URL_ENV`, `API_KEY_ENV`, `SIGNING_SECRET_ENV`, … |
|
|
311
|
+
| `WebhookEvent` | Parsed `action`, optional `entity_id`, optional `after` |
|
|
312
|
+
| `WebhookVerificationError` | Invalid/missing signature or malformed payload |
|
|
313
|
+
| `SIGNATURE_HEADER` | `"X-Diff-Signature"` |
|
|
314
|
+
|
|
315
|
+
Direct imports also work: `from streamgine.diff import DiffClient`.
|
|
316
|
+
|
|
317
|
+
## Development
|
|
318
|
+
|
|
319
|
+
```bash
|
|
320
|
+
cd clients/python
|
|
321
|
+
python -m pip install -e ".[dev]"
|
|
322
|
+
pytest
|
|
323
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
streamgine/__init__.py,sha256=9uinvKB4w1UJZZiRLJ6v1isjYDS_SjQoWnHaxQXWAmw,87
|
|
2
|
+
streamgine/diff/__init__.py,sha256=ugmS54EVUYNRv82jeEdq9WKCKfAqKt9qc9sX_T0Psc4,1125
|
|
3
|
+
streamgine/diff/client.py,sha256=sPr56Vi1JtoSLhKZdRrwZ8AdFzSv4AKttnTNH7W8MUc,6414
|
|
4
|
+
streamgine/diff/constants.py,sha256=eWVxwcN8Um3bVVANsnb4PBURT2mjFAykke0gxjSk1_U,169
|
|
5
|
+
streamgine/diff/credentials.py,sha256=1rNsi-Vjlc3uuQqVNbBniII8xWNWiU35P927GC-fRO4,5034
|
|
6
|
+
streamgine/diff/exceptions.py,sha256=5GYmvo0IYIpK9cUVGpDXxlhiQc43zvDc_kNwffz6wDc,235
|
|
7
|
+
streamgine/diff/models.py,sha256=r3NyREFZ5SSmuWl07eFHLex90Nd_mL13_Kd0_4F2rl4,894
|
|
8
|
+
streamgine/diff/webhooks.py,sha256=YzKhNEC-yjwdhW6aLJ7-qLKAjtNjPzZf8DV0jeHAwIc,2618
|
|
9
|
+
streamgine-1.0.0.dist-info/METADATA,sha256=TH8lmn8rsfEaK1OmzHqKpDslyamtXLM0kXeMliqMeaw,11555
|
|
10
|
+
streamgine-1.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
streamgine-1.0.0.dist-info/RECORD,,
|