hypeline 0.1.1__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.
- hypeline-0.1.1/PKG-INFO +33 -0
- hypeline-0.1.1/README.md +19 -0
- hypeline-0.1.1/hypeline/__init__.py +190 -0
- hypeline-0.1.1/hypeline/stream.py +192 -0
- hypeline-0.1.1/hypeline/webhook.py +43 -0
- hypeline-0.1.1/hypeline.egg-info/PKG-INFO +33 -0
- hypeline-0.1.1/hypeline.egg-info/SOURCES.txt +77 -0
- hypeline-0.1.1/hypeline.egg-info/dependency_links.txt +1 -0
- hypeline-0.1.1/hypeline.egg-info/requires.txt +5 -0
- hypeline-0.1.1/hypeline.egg-info/top_level.txt +2 -0
- hypeline-0.1.1/hypeline_core/__init__.py +84 -0
- hypeline-0.1.1/hypeline_core/api/__init__.py +11 -0
- hypeline-0.1.1/hypeline_core/api/event_kinds_api.py +285 -0
- hypeline-0.1.1/hypeline_core/api/history_api.py +816 -0
- hypeline-0.1.1/hypeline_core/api/render_api.py +313 -0
- hypeline-0.1.1/hypeline_core/api/sources_api.py +2494 -0
- hypeline-0.1.1/hypeline_core/api/stream_api.py +391 -0
- hypeline-0.1.1/hypeline_core/api/templates_api.py +1662 -0
- hypeline-0.1.1/hypeline_core/api/transparency_log_api.py +634 -0
- hypeline-0.1.1/hypeline_core/api_client.py +797 -0
- hypeline-0.1.1/hypeline_core/api_response.py +21 -0
- hypeline-0.1.1/hypeline_core/configuration.py +603 -0
- hypeline-0.1.1/hypeline_core/exceptions.py +199 -0
- hypeline-0.1.1/hypeline_core/models/__init__.py +61 -0
- hypeline-0.1.1/hypeline_core/models/alert_result.py +95 -0
- hypeline-0.1.1/hypeline_core/models/alert_spec.py +98 -0
- hypeline-0.1.1/hypeline_core/models/content_diff.py +116 -0
- hypeline-0.1.1/hypeline_core/models/copy.py +94 -0
- hypeline-0.1.1/hypeline_core/models/create_source_request.py +141 -0
- hypeline-0.1.1/hypeline_core/models/delivery.py +94 -0
- hypeline-0.1.1/hypeline_core/models/delta_op.py +95 -0
- hypeline-0.1.1/hypeline_core/models/delta_stats.py +93 -0
- hypeline-0.1.1/hypeline_core/models/detect_dto.py +99 -0
- hypeline-0.1.1/hypeline_core/models/detect_source_request.py +91 -0
- hypeline-0.1.1/hypeline_core/models/diff_segment.py +89 -0
- hypeline-0.1.1/hypeline_core/models/error_detail.py +96 -0
- hypeline-0.1.1/hypeline_core/models/error_model.py +114 -0
- hypeline-0.1.1/hypeline_core/models/event_kind_dto.py +102 -0
- hypeline-0.1.1/hypeline_core/models/health_dto.py +102 -0
- hypeline-0.1.1/hypeline_core/models/history_page.py +115 -0
- hypeline-0.1.1/hypeline_core/models/inclusion.py +95 -0
- hypeline-0.1.1/hypeline_core/models/instantiate_adhoc_template_request.py +110 -0
- hypeline-0.1.1/hypeline_core/models/instantiate_template_request.py +95 -0
- hypeline-0.1.1/hypeline_core/models/manifest.py +158 -0
- hypeline-0.1.1/hypeline_core/models/next_steps.py +89 -0
- hypeline-0.1.1/hypeline_core/models/parameter.py +101 -0
- hypeline-0.1.1/hypeline_core/models/pdf_page_span.py +91 -0
- hypeline-0.1.1/hypeline_core/models/picker_box.py +99 -0
- hypeline-0.1.1/hypeline_core/models/picker_dto.py +114 -0
- hypeline-0.1.1/hypeline_core/models/preview_row_dto.py +95 -0
- hypeline-0.1.1/hypeline_core/models/preview_template_request.py +91 -0
- hypeline-0.1.1/hypeline_core/models/receipt.py +115 -0
- hypeline-0.1.1/hypeline_core/models/render_doc_body.py +123 -0
- hypeline-0.1.1/hypeline_core/models/render_doc_viewport.py +93 -0
- hypeline-0.1.1/hypeline_core/models/render_doc_wait.py +89 -0
- hypeline-0.1.1/hypeline_core/models/render_multi_response.py +97 -0
- hypeline-0.1.1/hypeline_core/models/report.py +137 -0
- hypeline-0.1.1/hypeline_core/models/signed_tree_head.py +98 -0
- hypeline-0.1.1/hypeline_core/models/source_dto.py +160 -0
- hypeline-0.1.1/hypeline_core/models/source_leaf_entry.py +100 -0
- hypeline-0.1.1/hypeline_core/models/source_log_page.py +114 -0
- hypeline-0.1.1/hypeline_core/models/source_result.py +95 -0
- hypeline-0.1.1/hypeline_core/models/source_spec.py +106 -0
- hypeline-0.1.1/hypeline_core/models/stream_event.py +192 -0
- hypeline-0.1.1/hypeline_core/models/summary.py +93 -0
- hypeline-0.1.1/hypeline_core/models/template_summary_dto.py +104 -0
- hypeline-0.1.1/hypeline_core/models/text_delta.py +112 -0
- hypeline-0.1.1/hypeline_core/models/typed_payload.py +100 -0
- hypeline-0.1.1/hypeline_core/models/update_source_request.py +141 -0
- hypeline-0.1.1/hypeline_core/py.typed +0 -0
- hypeline-0.1.1/hypeline_core/rest.py +257 -0
- hypeline-0.1.1/pyproject.toml +26 -0
- hypeline-0.1.1/setup.cfg +4 -0
- hypeline-0.1.1/tests/test_changes.py +33 -0
- hypeline-0.1.1/tests/test_event_shape.py +27 -0
- hypeline-0.1.1/tests/test_stream.py +95 -0
- hypeline-0.1.1/tests/test_surface.py +31 -0
- hypeline-0.1.1/tests/test_watch.py +62 -0
- hypeline-0.1.1/tests/test_webhook.py +61 -0
hypeline-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hypeline
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Hypeline SDK: turn any source into a unified, deduplicated live stream of change events. Watches, change history, SSE with gap-free resume, and Standard Webhooks signature verification.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: hypeline,monitoring,webhooks,sse,change-detection,agents
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: urllib3<3.0.0,>=1.25.3
|
|
10
|
+
Requires-Dist: python_dateutil>=2.8.2
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Requires-Dist: typing-extensions>=4.7.1
|
|
13
|
+
Requires-Dist: standardwebhooks>=1.0.0
|
|
14
|
+
|
|
15
|
+
# hypeline
|
|
16
|
+
|
|
17
|
+
Official Python SDK for Hypeline. Create watches on any source URL, poll change
|
|
18
|
+
history, subscribe to the live event stream over SSE (gap-free resume), and verify
|
|
19
|
+
signed webhooks.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from hypeline import Hypeline
|
|
23
|
+
|
|
24
|
+
hy = Hypeline(api_key="...")
|
|
25
|
+
watch = hy.create_watch("https://example.com/blog", keywords=["incident"])
|
|
26
|
+
for change in hy.get_changes_since(watch.cursor).events:
|
|
27
|
+
print(change.content)
|
|
28
|
+
|
|
29
|
+
for ev in hy.stream(keyword="incident"):
|
|
30
|
+
print(ev.emitted_at, ev.content)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See `sdk/README.md` for the shared regen/drift workflow.
|
hypeline-0.1.1/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# hypeline
|
|
2
|
+
|
|
3
|
+
Official Python SDK for Hypeline. Create watches on any source URL, poll change
|
|
4
|
+
history, subscribe to the live event stream over SSE (gap-free resume), and verify
|
|
5
|
+
signed webhooks.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from hypeline import Hypeline
|
|
9
|
+
|
|
10
|
+
hy = Hypeline(api_key="...")
|
|
11
|
+
watch = hy.create_watch("https://example.com/blog", keywords=["incident"])
|
|
12
|
+
for change in hy.get_changes_since(watch.cursor).events:
|
|
13
|
+
print(change.content)
|
|
14
|
+
|
|
15
|
+
for ev in hy.stream(keyword="incident"):
|
|
16
|
+
print(ev.emitted_at, ev.content)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
See `sdk/README.md` for the shared regen/drift workflow.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""hypeline: the official Python SDK.
|
|
2
|
+
|
|
3
|
+
Wraps the generated core (``hypeline_core``, openapi-generator) and re-exposes exactly
|
|
4
|
+
the marketed snake_case surface:
|
|
5
|
+
create_watch, list_watches, delete_watch, get_changes_since, stream, verify_signature
|
|
6
|
+
|
|
7
|
+
The event model mirrors the REST/SSE wire (``content`` + ``emitted_at``, ``simhash`` a
|
|
8
|
+
string), NOT the MCP projection (``summary``/``occurred_at``).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Iterator, List, Optional
|
|
16
|
+
|
|
17
|
+
from hypeline_core.api.history_api import HistoryApi
|
|
18
|
+
from hypeline_core.api.sources_api import SourcesApi
|
|
19
|
+
from hypeline_core.api_client import ApiClient
|
|
20
|
+
from hypeline_core.configuration import Configuration
|
|
21
|
+
from hypeline_core.models.create_source_request import CreateSourceRequest
|
|
22
|
+
from hypeline_core.models.history_page import HistoryPage
|
|
23
|
+
from hypeline_core.models.source_dto import SourceDTO
|
|
24
|
+
from hypeline_core.models.stream_event import StreamEvent
|
|
25
|
+
|
|
26
|
+
from .stream import CursorExpired, stream_events
|
|
27
|
+
from .webhook import verify_signature
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Hypeline",
|
|
31
|
+
"Watch",
|
|
32
|
+
"StreamEvent",
|
|
33
|
+
"SourceDTO",
|
|
34
|
+
"HistoryPage",
|
|
35
|
+
"CursorExpired",
|
|
36
|
+
"verify_signature",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
DEFAULT_BASE_URL = "https://api.hypeline.io"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Watch:
|
|
44
|
+
"""A created watch: the source id plus a head cursor to poll get_changes_since from."""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
cursor: str
|
|
48
|
+
source: SourceDTO
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Hypeline:
|
|
52
|
+
"""The idiomatic Hypeline client."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
access_token: Optional[str] = None,
|
|
58
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._base_url = base_url.rstrip("/")
|
|
61
|
+
self._api_key = api_key
|
|
62
|
+
self._access_token = access_token
|
|
63
|
+
config = Configuration(host=self._base_url)
|
|
64
|
+
if api_key:
|
|
65
|
+
# Auth scheme name from the overlay: ApiKeyAuth -> header X-API-Key.
|
|
66
|
+
config.api_key = {"ApiKeyAuth": api_key}
|
|
67
|
+
if access_token:
|
|
68
|
+
config.access_token = access_token
|
|
69
|
+
self._client = ApiClient(config)
|
|
70
|
+
self._sources = SourcesApi(self._client)
|
|
71
|
+
self._history = HistoryApi(self._client)
|
|
72
|
+
|
|
73
|
+
# --- watches ---------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def create_watch(
|
|
76
|
+
self,
|
|
77
|
+
url: str,
|
|
78
|
+
keywords: Optional[List[str]] = None,
|
|
79
|
+
type: Optional[str] = None,
|
|
80
|
+
) -> Watch:
|
|
81
|
+
"""Create a watch on a source URL. ``keywords`` are stored as source ``tags``.
|
|
82
|
+
|
|
83
|
+
Returns the watch id plus a head cursor (the current newest event id) so the
|
|
84
|
+
first ``get_changes_since(cursor)`` receives only changes after creation.
|
|
85
|
+
"""
|
|
86
|
+
req = CreateSourceRequest(
|
|
87
|
+
url=url,
|
|
88
|
+
tags=keywords if keywords else None,
|
|
89
|
+
type=type,
|
|
90
|
+
)
|
|
91
|
+
source = self._sources.create_source(create_source_request=req)
|
|
92
|
+
return Watch(id=source.id, cursor=self._head_cursor(), source=source)
|
|
93
|
+
|
|
94
|
+
def list_watches(self) -> List[SourceDTO]:
|
|
95
|
+
"""List all watches (sources) for the authenticated key."""
|
|
96
|
+
return self._sources.list_sources()
|
|
97
|
+
|
|
98
|
+
def delete_watch(self, watch_id: str) -> None:
|
|
99
|
+
"""Delete a watch by id."""
|
|
100
|
+
self._sources.delete_source(id=watch_id)
|
|
101
|
+
|
|
102
|
+
# --- changes ---------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def get_changes_since(
|
|
105
|
+
self,
|
|
106
|
+
cursor: Optional[str] = None,
|
|
107
|
+
limit: Optional[int] = None,
|
|
108
|
+
source: Optional[Any] = None,
|
|
109
|
+
keyword: Optional[Any] = None,
|
|
110
|
+
) -> HistoryPage:
|
|
111
|
+
"""Fetch changes strictly after ``cursor`` (oldest-first).
|
|
112
|
+
|
|
113
|
+
Returns a ``HistoryPage`` whose ``.events`` are typed ``StreamEvent`` objects and
|
|
114
|
+
whose ``.next_cursor`` pages the next call (``None`` once caught up).
|
|
115
|
+
"""
|
|
116
|
+
return self._history.query_events(
|
|
117
|
+
var_from=cursor,
|
|
118
|
+
order="asc",
|
|
119
|
+
limit=limit,
|
|
120
|
+
source=_as_list(source),
|
|
121
|
+
keyword=_as_list(keyword),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# --- live stream -----------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def stream(
|
|
127
|
+
self,
|
|
128
|
+
keyword: Optional[str] = None,
|
|
129
|
+
source: Optional[Any] = None,
|
|
130
|
+
source_type: Optional[str] = None,
|
|
131
|
+
tag: Optional[Any] = None,
|
|
132
|
+
since: Optional[str] = None,
|
|
133
|
+
) -> Iterator[StreamEvent]:
|
|
134
|
+
"""Subscribe to the live stream (SSE), resuming gap-free on disconnect.
|
|
135
|
+
|
|
136
|
+
The filter is a SINGULAR ``keyword`` string (mapped to ``?keyword=``); there is
|
|
137
|
+
no plural ``keywords`` list here (that belongs to ``create_watch`` -> ``tags``).
|
|
138
|
+
A 410 raises :class:`CursorExpired`.
|
|
139
|
+
"""
|
|
140
|
+
return stream_events(
|
|
141
|
+
self._base_url,
|
|
142
|
+
api_key=self._api_key,
|
|
143
|
+
access_token=self._access_token,
|
|
144
|
+
keyword=keyword,
|
|
145
|
+
source=source,
|
|
146
|
+
source_type=source_type,
|
|
147
|
+
tag=tag,
|
|
148
|
+
since=since,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# --- webhooks --------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def verify_signature(self, secret: str, headers: dict, body: Any) -> Any:
|
|
154
|
+
"""Verify a signed webhook delivery (see :func:`hypeline.verify_signature`)."""
|
|
155
|
+
return verify_signature(secret, headers, body)
|
|
156
|
+
|
|
157
|
+
# --- internals -------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _head_cursor(self) -> str:
|
|
160
|
+
"""Current head event id (mirrors MCP headCursor: GET /v1/events?order=desc&limit=1).
|
|
161
|
+
|
|
162
|
+
On an empty log or any failure, synthesize a time-ordered cursor so polling still
|
|
163
|
+
starts from the present.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
page = self._history.query_events(order="desc", limit=1)
|
|
167
|
+
events = page.events or []
|
|
168
|
+
if events and events[0].id:
|
|
169
|
+
return events[0].id
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
pass
|
|
172
|
+
return _synthesize_cursor()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _as_list(val: Any) -> Optional[List[str]]:
|
|
176
|
+
if val is None:
|
|
177
|
+
return None
|
|
178
|
+
return list(val) if isinstance(val, (list, tuple)) else [val]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _synthesize_cursor() -> str:
|
|
182
|
+
"""A UUIDv7-ish time-ordered id used as a 'now' cursor fallback."""
|
|
183
|
+
ms = int(time.time() * 1000)
|
|
184
|
+
time_hex = f"{ms:012x}"
|
|
185
|
+
rand = f"{random.getrandbits(64):016x}"
|
|
186
|
+
var = random.choice("89ab")
|
|
187
|
+
return (
|
|
188
|
+
f"{time_hex[:8]}-{time_hex[8:12]}-7{rand[:3]}-"
|
|
189
|
+
f"{var}{rand[3:6]}-{rand[6:16]}{rand[:2]}"
|
|
190
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Hand-written Server-Sent Events resume shim over GET /v1/stream.
|
|
2
|
+
|
|
3
|
+
The generated core (openapi-generator python) models /v1/stream as a one-shot JSON
|
|
4
|
+
response, so it cannot iterate SSE frames. This shim owns the SSE wire directly
|
|
5
|
+
(matching internal/api/stream.go):
|
|
6
|
+
|
|
7
|
+
- frames: ``id: <uuidv7>`` (the resume cursor), ``event: <source_type>``, ``data: <json>``
|
|
8
|
+
- ``: keepalive`` comment frames are heartbeats and are skipped (never yielded)
|
|
9
|
+
- reconnect on disconnect sends the ``Last-Event-ID`` header and resumes gap-free
|
|
10
|
+
- a server ``retry:`` hint sets the reconnect backoff
|
|
11
|
+
- HTTP 410 Gone (a resume cursor past the retained window) raises ``CursorExpired``
|
|
12
|
+
|
|
13
|
+
The stream filter is a SINGULAR ``keyword`` string (mapped to ``?keyword=``), matching
|
|
14
|
+
the marketed ``/learn`` copy; the PLURAL ``keywords`` list belongs only to
|
|
15
|
+
``create_watch`` (mapped to source ``tags``).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Any, Callable, Iterator, List, Optional
|
|
24
|
+
from urllib.parse import urlencode, urljoin
|
|
25
|
+
|
|
26
|
+
from hypeline_core.models.stream_event import StreamEvent
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CursorExpired(Exception):
|
|
30
|
+
"""Raised when a resume cursor predates the retained backfill window (HTTP 410)."""
|
|
31
|
+
|
|
32
|
+
status = 410
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: Optional[str] = None) -> None:
|
|
35
|
+
super().__init__(
|
|
36
|
+
message
|
|
37
|
+
or "stream cursor expired: the Last-Event-ID predates the retained window; "
|
|
38
|
+
"reconnect without it to resume from the live tail"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# A minimal opener signature: (url, headers) -> a file-like response with .read()/
|
|
43
|
+
# .readline() and a .status/.getcode(). Defaults to urllib.request.urlopen; injectable
|
|
44
|
+
# for tests that want to avoid a real socket (the suite uses a real localhost server).
|
|
45
|
+
Opener = Callable[[urllib.request.Request], Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _build_url(
|
|
49
|
+
base_url: str,
|
|
50
|
+
source: Optional[Any],
|
|
51
|
+
source_type: Optional[str],
|
|
52
|
+
tag: Optional[Any],
|
|
53
|
+
keyword: Optional[str],
|
|
54
|
+
since: Optional[str],
|
|
55
|
+
) -> str:
|
|
56
|
+
params: List[tuple] = []
|
|
57
|
+
|
|
58
|
+
def add(key: str, val: Any) -> None:
|
|
59
|
+
if val is None:
|
|
60
|
+
return
|
|
61
|
+
for v in val if isinstance(val, (list, tuple)) else [val]:
|
|
62
|
+
if v != "":
|
|
63
|
+
params.append((key, v))
|
|
64
|
+
|
|
65
|
+
add("source", source)
|
|
66
|
+
add("source_type", source_type)
|
|
67
|
+
add("tag", tag)
|
|
68
|
+
add("keyword", keyword) # SINGULAR; never a plural keywords list
|
|
69
|
+
add("since", since)
|
|
70
|
+
base = base_url if base_url.endswith("/") else base_url + "/"
|
|
71
|
+
url = urljoin(base, "v1/stream")
|
|
72
|
+
if params:
|
|
73
|
+
url = url + "?" + urlencode(params)
|
|
74
|
+
return url
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_frame(raw: str):
|
|
78
|
+
"""Parse a single SSE frame; returns (event_id, data_str, retry_ms, is_comment)."""
|
|
79
|
+
data_lines: List[str] = []
|
|
80
|
+
event_id: Optional[str] = None
|
|
81
|
+
retry_ms: Optional[int] = None
|
|
82
|
+
is_comment = False
|
|
83
|
+
for line in raw.split("\n"):
|
|
84
|
+
if line == "":
|
|
85
|
+
continue
|
|
86
|
+
if line.startswith(":"):
|
|
87
|
+
is_comment = True
|
|
88
|
+
continue
|
|
89
|
+
idx = line.find(":")
|
|
90
|
+
field = line if idx == -1 else line[:idx]
|
|
91
|
+
value = "" if idx == -1 else line[idx + 1 :]
|
|
92
|
+
if value.startswith(" "):
|
|
93
|
+
value = value[1:]
|
|
94
|
+
if field == "id":
|
|
95
|
+
event_id = value
|
|
96
|
+
elif field == "data":
|
|
97
|
+
data_lines.append(value)
|
|
98
|
+
elif field == "retry":
|
|
99
|
+
try:
|
|
100
|
+
retry_ms = int(value)
|
|
101
|
+
except ValueError:
|
|
102
|
+
pass
|
|
103
|
+
return event_id, "\n".join(data_lines), retry_ms, is_comment
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def stream_events(
|
|
107
|
+
base_url: str,
|
|
108
|
+
api_key: Optional[str] = None,
|
|
109
|
+
access_token: Optional[str] = None,
|
|
110
|
+
*,
|
|
111
|
+
source: Optional[Any] = None,
|
|
112
|
+
source_type: Optional[str] = None,
|
|
113
|
+
tag: Optional[Any] = None,
|
|
114
|
+
keyword: Optional[str] = None,
|
|
115
|
+
since: Optional[str] = None,
|
|
116
|
+
opener: Optional[Opener] = None,
|
|
117
|
+
reconnect_delay: float = 1.0,
|
|
118
|
+
) -> Iterator[StreamEvent]:
|
|
119
|
+
"""Iterate the live stream, resuming gap-free across disconnects.
|
|
120
|
+
|
|
121
|
+
Yields typed ``StreamEvent`` objects. On a mid-stream disconnect it reconnects with
|
|
122
|
+
the ``Last-Event-ID`` header set to the last seen id, so no event is skipped or
|
|
123
|
+
repeated. A 410 raises :class:`CursorExpired` (terminal, no reconnect).
|
|
124
|
+
"""
|
|
125
|
+
do_open: Opener = opener or (lambda req: urllib.request.urlopen(req))
|
|
126
|
+
url = _build_url(base_url, source, source_type, tag, keyword, since)
|
|
127
|
+
last_id: Optional[str] = since
|
|
128
|
+
retry = reconnect_delay
|
|
129
|
+
|
|
130
|
+
while True:
|
|
131
|
+
headers = {"Accept": "text/event-stream"}
|
|
132
|
+
if api_key:
|
|
133
|
+
headers["X-API-Key"] = api_key
|
|
134
|
+
if access_token:
|
|
135
|
+
headers["Authorization"] = f"Bearer {access_token}"
|
|
136
|
+
if last_id:
|
|
137
|
+
headers["Last-Event-ID"] = last_id
|
|
138
|
+
|
|
139
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
140
|
+
try:
|
|
141
|
+
resp = do_open(req)
|
|
142
|
+
except urllib.error.HTTPError as err:
|
|
143
|
+
if err.code == 410:
|
|
144
|
+
raise CursorExpired() from None
|
|
145
|
+
if 400 <= err.code < 500:
|
|
146
|
+
raise
|
|
147
|
+
time.sleep(retry)
|
|
148
|
+
continue
|
|
149
|
+
except urllib.error.URLError:
|
|
150
|
+
time.sleep(retry)
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
frame_lines: List[str] = []
|
|
154
|
+
disconnected = False
|
|
155
|
+
try:
|
|
156
|
+
while True:
|
|
157
|
+
raw_line = resp.readline()
|
|
158
|
+
if not raw_line:
|
|
159
|
+
disconnected = True
|
|
160
|
+
break
|
|
161
|
+
line = raw_line.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
|
|
162
|
+
if line != "":
|
|
163
|
+
frame_lines.append(line)
|
|
164
|
+
continue
|
|
165
|
+
# Blank line: end of frame.
|
|
166
|
+
if not frame_lines:
|
|
167
|
+
continue
|
|
168
|
+
event_id, data, retry_ms, is_comment = _parse_frame("\n".join(frame_lines))
|
|
169
|
+
frame_lines = []
|
|
170
|
+
if retry_ms is not None:
|
|
171
|
+
retry = retry_ms / 1000.0
|
|
172
|
+
if event_id:
|
|
173
|
+
last_id = event_id
|
|
174
|
+
if is_comment or data == "":
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
payload = json.loads(data)
|
|
178
|
+
except json.JSONDecodeError:
|
|
179
|
+
continue
|
|
180
|
+
yield StreamEvent.from_dict(payload)
|
|
181
|
+
except (urllib.error.URLError, OSError):
|
|
182
|
+
disconnected = True
|
|
183
|
+
finally:
|
|
184
|
+
try:
|
|
185
|
+
resp.close()
|
|
186
|
+
except Exception: # noqa: BLE001
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
if disconnected:
|
|
190
|
+
time.sleep(retry)
|
|
191
|
+
continue
|
|
192
|
+
return
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Webhook signature verification for Hypeline deliveries.
|
|
2
|
+
|
|
3
|
+
Hypeline signs webhooks with the Standard Webhooks scheme (the same scheme the engine
|
|
4
|
+
implements in internal/delivery/sign.go). We do NOT hand-roll HMAC here:
|
|
5
|
+
``verify_signature`` wraps the official ``standardwebhooks`` library, which strips the
|
|
6
|
+
``whsec_`` prefix, base64-decodes the secret, checks the three ``webhook-*`` headers,
|
|
7
|
+
enforces the 5-minute timestamp freshness window, and constant-time compares the v1
|
|
8
|
+
HMAC-SHA256 signature.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Dict, Union
|
|
13
|
+
|
|
14
|
+
from standardwebhooks import Webhook
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_signature(
|
|
18
|
+
secret: str,
|
|
19
|
+
headers: Dict[str, str],
|
|
20
|
+
body: Union[str, bytes],
|
|
21
|
+
) -> Any:
|
|
22
|
+
"""Verify a Hypeline webhook delivery.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
secret: The endpoint's signing secret (the ``whsec_...`` value shown when the
|
|
26
|
+
webhook was created). Passed through to ``standardwebhooks`` verbatim.
|
|
27
|
+
headers: The delivery's HTTP headers. Must include ``webhook-id``,
|
|
28
|
+
``webhook-timestamp`` and ``webhook-signature`` (header keys are normalized
|
|
29
|
+
to lower-case before verification).
|
|
30
|
+
body: The RAW request body (verify BEFORE parsing JSON: any re-serialization
|
|
31
|
+
changes bytes and breaks the signature).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The parsed JSON payload when the signature and timestamp are valid.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
WebhookVerificationError: When the signature does not match, a required header
|
|
38
|
+
is missing, or the timestamp is outside the Standard Webhooks tolerance.
|
|
39
|
+
"""
|
|
40
|
+
normalized = {str(k).lower(): v for k, v in headers.items()}
|
|
41
|
+
data = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else body
|
|
42
|
+
wh = Webhook(secret)
|
|
43
|
+
return wh.verify(data, normalized)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hypeline
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Official Hypeline SDK: turn any source into a unified, deduplicated live stream of change events. Watches, change history, SSE with gap-free resume, and Standard Webhooks signature verification.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: hypeline,monitoring,webhooks,sse,change-detection,agents
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: urllib3<3.0.0,>=1.25.3
|
|
10
|
+
Requires-Dist: python_dateutil>=2.8.2
|
|
11
|
+
Requires-Dist: pydantic>=2
|
|
12
|
+
Requires-Dist: typing-extensions>=4.7.1
|
|
13
|
+
Requires-Dist: standardwebhooks>=1.0.0
|
|
14
|
+
|
|
15
|
+
# hypeline
|
|
16
|
+
|
|
17
|
+
Official Python SDK for Hypeline. Create watches on any source URL, poll change
|
|
18
|
+
history, subscribe to the live event stream over SSE (gap-free resume), and verify
|
|
19
|
+
signed webhooks.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from hypeline import Hypeline
|
|
23
|
+
|
|
24
|
+
hy = Hypeline(api_key="...")
|
|
25
|
+
watch = hy.create_watch("https://example.com/blog", keywords=["incident"])
|
|
26
|
+
for change in hy.get_changes_since(watch.cursor).events:
|
|
27
|
+
print(change.content)
|
|
28
|
+
|
|
29
|
+
for ev in hy.stream(keyword="incident"):
|
|
30
|
+
print(ev.emitted_at, ev.content)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
See `sdk/README.md` for the shared regen/drift workflow.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
hypeline/__init__.py
|
|
4
|
+
hypeline/stream.py
|
|
5
|
+
hypeline/webhook.py
|
|
6
|
+
hypeline.egg-info/PKG-INFO
|
|
7
|
+
hypeline.egg-info/SOURCES.txt
|
|
8
|
+
hypeline.egg-info/dependency_links.txt
|
|
9
|
+
hypeline.egg-info/requires.txt
|
|
10
|
+
hypeline.egg-info/top_level.txt
|
|
11
|
+
hypeline_core/__init__.py
|
|
12
|
+
hypeline_core/api_client.py
|
|
13
|
+
hypeline_core/api_response.py
|
|
14
|
+
hypeline_core/configuration.py
|
|
15
|
+
hypeline_core/exceptions.py
|
|
16
|
+
hypeline_core/py.typed
|
|
17
|
+
hypeline_core/rest.py
|
|
18
|
+
hypeline_core/api/__init__.py
|
|
19
|
+
hypeline_core/api/event_kinds_api.py
|
|
20
|
+
hypeline_core/api/history_api.py
|
|
21
|
+
hypeline_core/api/render_api.py
|
|
22
|
+
hypeline_core/api/sources_api.py
|
|
23
|
+
hypeline_core/api/stream_api.py
|
|
24
|
+
hypeline_core/api/templates_api.py
|
|
25
|
+
hypeline_core/api/transparency_log_api.py
|
|
26
|
+
hypeline_core/models/__init__.py
|
|
27
|
+
hypeline_core/models/alert_result.py
|
|
28
|
+
hypeline_core/models/alert_spec.py
|
|
29
|
+
hypeline_core/models/content_diff.py
|
|
30
|
+
hypeline_core/models/copy.py
|
|
31
|
+
hypeline_core/models/create_source_request.py
|
|
32
|
+
hypeline_core/models/delivery.py
|
|
33
|
+
hypeline_core/models/delta_op.py
|
|
34
|
+
hypeline_core/models/delta_stats.py
|
|
35
|
+
hypeline_core/models/detect_dto.py
|
|
36
|
+
hypeline_core/models/detect_source_request.py
|
|
37
|
+
hypeline_core/models/diff_segment.py
|
|
38
|
+
hypeline_core/models/error_detail.py
|
|
39
|
+
hypeline_core/models/error_model.py
|
|
40
|
+
hypeline_core/models/event_kind_dto.py
|
|
41
|
+
hypeline_core/models/health_dto.py
|
|
42
|
+
hypeline_core/models/history_page.py
|
|
43
|
+
hypeline_core/models/inclusion.py
|
|
44
|
+
hypeline_core/models/instantiate_adhoc_template_request.py
|
|
45
|
+
hypeline_core/models/instantiate_template_request.py
|
|
46
|
+
hypeline_core/models/manifest.py
|
|
47
|
+
hypeline_core/models/next_steps.py
|
|
48
|
+
hypeline_core/models/parameter.py
|
|
49
|
+
hypeline_core/models/pdf_page_span.py
|
|
50
|
+
hypeline_core/models/picker_box.py
|
|
51
|
+
hypeline_core/models/picker_dto.py
|
|
52
|
+
hypeline_core/models/preview_row_dto.py
|
|
53
|
+
hypeline_core/models/preview_template_request.py
|
|
54
|
+
hypeline_core/models/receipt.py
|
|
55
|
+
hypeline_core/models/render_doc_body.py
|
|
56
|
+
hypeline_core/models/render_doc_viewport.py
|
|
57
|
+
hypeline_core/models/render_doc_wait.py
|
|
58
|
+
hypeline_core/models/render_multi_response.py
|
|
59
|
+
hypeline_core/models/report.py
|
|
60
|
+
hypeline_core/models/signed_tree_head.py
|
|
61
|
+
hypeline_core/models/source_dto.py
|
|
62
|
+
hypeline_core/models/source_leaf_entry.py
|
|
63
|
+
hypeline_core/models/source_log_page.py
|
|
64
|
+
hypeline_core/models/source_result.py
|
|
65
|
+
hypeline_core/models/source_spec.py
|
|
66
|
+
hypeline_core/models/stream_event.py
|
|
67
|
+
hypeline_core/models/summary.py
|
|
68
|
+
hypeline_core/models/template_summary_dto.py
|
|
69
|
+
hypeline_core/models/text_delta.py
|
|
70
|
+
hypeline_core/models/typed_payload.py
|
|
71
|
+
hypeline_core/models/update_source_request.py
|
|
72
|
+
tests/test_changes.py
|
|
73
|
+
tests/test_event_shape.py
|
|
74
|
+
tests/test_stream.py
|
|
75
|
+
tests/test_surface.py
|
|
76
|
+
tests/test_watch.py
|
|
77
|
+
tests/test_webhook.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
|
|
3
|
+
# flake8: noqa
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Hypeline Stream API
|
|
7
|
+
|
|
8
|
+
Subscribe to Hypeline's unified, deduplicated live event stream over Server-Sent Events. Latency is honest and per-source-tier: push sources arrive in seconds, RSS/JSON feeds in minutes, and feed-less HTML-diff sources in minutes to tens of minutes. Resume gap-free with the Last-Event-ID cursor; a cursor older than the retained window returns 410 Gone.
|
|
9
|
+
|
|
10
|
+
The version of the OpenAPI document: 1.0.0
|
|
11
|
+
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
|
12
|
+
|
|
13
|
+
Do not edit the class manually.
|
|
14
|
+
""" # noqa: E501
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__version__ = "1.0.0"
|
|
18
|
+
|
|
19
|
+
# import apis into sdk package
|
|
20
|
+
from hypeline_core.api.event_kinds_api import EventKindsApi
|
|
21
|
+
from hypeline_core.api.history_api import HistoryApi
|
|
22
|
+
from hypeline_core.api.render_api import RenderApi
|
|
23
|
+
from hypeline_core.api.sources_api import SourcesApi
|
|
24
|
+
from hypeline_core.api.stream_api import StreamApi
|
|
25
|
+
from hypeline_core.api.templates_api import TemplatesApi
|
|
26
|
+
from hypeline_core.api.transparency_log_api import TransparencyLogApi
|
|
27
|
+
|
|
28
|
+
# import ApiClient
|
|
29
|
+
from hypeline_core.api_response import ApiResponse
|
|
30
|
+
from hypeline_core.api_client import ApiClient
|
|
31
|
+
from hypeline_core.configuration import Configuration
|
|
32
|
+
from hypeline_core.exceptions import OpenApiException
|
|
33
|
+
from hypeline_core.exceptions import ApiTypeError
|
|
34
|
+
from hypeline_core.exceptions import ApiValueError
|
|
35
|
+
from hypeline_core.exceptions import ApiKeyError
|
|
36
|
+
from hypeline_core.exceptions import ApiAttributeError
|
|
37
|
+
from hypeline_core.exceptions import ApiException
|
|
38
|
+
|
|
39
|
+
# import models into sdk package
|
|
40
|
+
from hypeline_core.models.alert_result import AlertResult
|
|
41
|
+
from hypeline_core.models.alert_spec import AlertSpec
|
|
42
|
+
from hypeline_core.models.content_diff import ContentDiff
|
|
43
|
+
from hypeline_core.models.copy import Copy
|
|
44
|
+
from hypeline_core.models.create_source_request import CreateSourceRequest
|
|
45
|
+
from hypeline_core.models.delivery import Delivery
|
|
46
|
+
from hypeline_core.models.delta_op import DeltaOp
|
|
47
|
+
from hypeline_core.models.delta_stats import DeltaStats
|
|
48
|
+
from hypeline_core.models.detect_dto import DetectDTO
|
|
49
|
+
from hypeline_core.models.detect_source_request import DetectSourceRequest
|
|
50
|
+
from hypeline_core.models.diff_segment import DiffSegment
|
|
51
|
+
from hypeline_core.models.error_detail import ErrorDetail
|
|
52
|
+
from hypeline_core.models.error_model import ErrorModel
|
|
53
|
+
from hypeline_core.models.event_kind_dto import EventKindDTO
|
|
54
|
+
from hypeline_core.models.health_dto import HealthDTO
|
|
55
|
+
from hypeline_core.models.history_page import HistoryPage
|
|
56
|
+
from hypeline_core.models.inclusion import Inclusion
|
|
57
|
+
from hypeline_core.models.instantiate_adhoc_template_request import InstantiateAdhocTemplateRequest
|
|
58
|
+
from hypeline_core.models.instantiate_template_request import InstantiateTemplateRequest
|
|
59
|
+
from hypeline_core.models.manifest import Manifest
|
|
60
|
+
from hypeline_core.models.next_steps import NextSteps
|
|
61
|
+
from hypeline_core.models.pdf_page_span import PDFPageSpan
|
|
62
|
+
from hypeline_core.models.parameter import Parameter
|
|
63
|
+
from hypeline_core.models.picker_box import PickerBox
|
|
64
|
+
from hypeline_core.models.picker_dto import PickerDTO
|
|
65
|
+
from hypeline_core.models.preview_row_dto import PreviewRowDTO
|
|
66
|
+
from hypeline_core.models.preview_template_request import PreviewTemplateRequest
|
|
67
|
+
from hypeline_core.models.receipt import Receipt
|
|
68
|
+
from hypeline_core.models.render_doc_body import RenderDocBody
|
|
69
|
+
from hypeline_core.models.render_doc_viewport import RenderDocViewport
|
|
70
|
+
from hypeline_core.models.render_doc_wait import RenderDocWait
|
|
71
|
+
from hypeline_core.models.render_multi_response import RenderMultiResponse
|
|
72
|
+
from hypeline_core.models.report import Report
|
|
73
|
+
from hypeline_core.models.signed_tree_head import SignedTreeHead
|
|
74
|
+
from hypeline_core.models.source_dto import SourceDTO
|
|
75
|
+
from hypeline_core.models.source_leaf_entry import SourceLeafEntry
|
|
76
|
+
from hypeline_core.models.source_log_page import SourceLogPage
|
|
77
|
+
from hypeline_core.models.source_result import SourceResult
|
|
78
|
+
from hypeline_core.models.source_spec import SourceSpec
|
|
79
|
+
from hypeline_core.models.stream_event import StreamEvent
|
|
80
|
+
from hypeline_core.models.summary import Summary
|
|
81
|
+
from hypeline_core.models.template_summary_dto import TemplateSummaryDTO
|
|
82
|
+
from hypeline_core.models.text_delta import TextDelta
|
|
83
|
+
from hypeline_core.models.typed_payload import TypedPayload
|
|
84
|
+
from hypeline_core.models.update_source_request import UpdateSourceRequest
|