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 ADDED
@@ -0,0 +1,5 @@
1
+ """Streamgine Python SDK."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ __all__ = ["__version__", "diff"]
@@ -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,5 @@
1
+ """Public constants for Diff by Streamgine routing and test heartbeats."""
2
+
3
+ TEST_ROUTING_STATE = "TEST"
4
+ TEST_ROUTING_FILING_TYPE = "test"
5
+ TEST_TOPIC = "diff-event.test"
@@ -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,9 @@
1
+ """Client and webhook errors."""
2
+
3
+
4
+ class DiffError(Exception):
5
+ """Base error for Diff by Streamgine client failures."""
6
+
7
+
8
+ class WebhookVerificationError(DiffError):
9
+ """Raised when an inbound webhook fails HMAC verification."""
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any