eventhook 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ # Build artifacts
2
+ dist/
3
+ *.egg-info/
4
+
5
+ # Virtual environments
6
+ .venv/
7
+
8
+ # Python bytecode
9
+ __pycache__/
10
+ *.pyc
11
+ *.pyo
12
+
13
+ # Test and debug artifacts
14
+ .pytest_cache/
15
+ FAILURE_ANALYSIS.md
16
+ *.log
17
+
18
+ # Generated proto stubs (regenerate with generate_proto.sh)
19
+ eventhook/_proto/
20
+
21
+ # Packaging lock file (not for libraries)
22
+ uv.lock
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: eventhook
3
+ Version: 0.1.0
4
+ Summary: Receive provider events in your app without exposing a public endpoint
5
+ Project-URL: Homepage, https://eventhook.dev
6
+ Project-URL: Repository, https://github.com/iyifr/eventhook
7
+ Project-URL: Issues, https://github.com/iyifr/eventhook/issues
8
+ License: MIT
9
+ Keywords: event-driven,events,grpc,real-time,webhooks
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Internet
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: cachetools<6,>=5.0
23
+ Requires-Dist: grpcio<3,>=1.60.0
24
+ Requires-Dist: protobuf<7,>=6.33.5
@@ -0,0 +1,5 @@
1
+ from ._client import EventHooks
2
+ from ._event import Ack, Event
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["EventHooks", "Ack", "Event", "__version__"]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+
5
+
6
+ class ExponentialBackoff:
7
+ """Full-jitter exponential backoff — never hammers the gateway on reconnect."""
8
+
9
+ def __init__(self, base: float = 1.0, cap: float = 60.0) -> None:
10
+ self._base = base
11
+ self._cap = cap
12
+ self._attempt = 0
13
+
14
+ def next(self) -> float:
15
+ ceiling = min(self._cap, self._base * (2 ** self._attempt))
16
+ self._attempt += 1
17
+ return random.uniform(0, ceiling) # full jitter
18
+
19
+ def reset(self) -> None:
20
+ self._attempt = 0
@@ -0,0 +1,364 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import inspect
7
+ import logging
8
+ import threading
9
+ import time
10
+ from typing import Callable
11
+
12
+ import grpc
13
+ import grpc.aio
14
+
15
+ from ._backoff import ExponentialBackoff
16
+ from ._dedup import DedupCache
17
+ from ._event import Ack, Event
18
+ from ._proto.eventbridge import eventbridge_pb2 as pb
19
+ from ._proto.eventbridge import eventbridge_pb2_grpc as pb_grpc
20
+
21
+ logger = logging.getLogger("eventhook")
22
+
23
+ # Pre-resolved once at import time — avoids a dict alloc + proto enum lookup per event.
24
+ _ACK_STATUS = {
25
+ Ack.SUCCESS: pb.Ack.Status.Value("DELIVERED"),
26
+ Ack.RETRY: pb.Ack.Status.Value("RETRYING"),
27
+ Ack.FAILED: pb.Ack.Status.Value("DEAD_LETTERED"),
28
+ }
29
+
30
+ # Keep the TCP connection alive through idle periods (long-running handlers,
31
+ # silent stretches between events). Without this, NATs and load balancers
32
+ # silently drop the connection and the stream appears open while being dead.
33
+ _KEEPALIVE_OPTIONS = [
34
+ ("grpc.keepalive_time_ms", 20_000),
35
+ ("grpc.keepalive_timeout_ms", 5_000),
36
+ ("grpc.keepalive_permit_without_calls", True),
37
+ ]
38
+
39
+
40
+ class EventHooks:
41
+ def __init__(
42
+ self,
43
+ *,
44
+ provider_id: str,
45
+ app_name: str,
46
+ api_key: str,
47
+ gateway_url: str = "localhost:50051",
48
+ tls: bool = False,
49
+ max_retries: int = 3,
50
+ ) -> None:
51
+ self._provider_id = provider_id
52
+ self._app_name = app_name
53
+ self._api_key = api_key
54
+ self._gateway_url = gateway_url
55
+ self._tls = tls
56
+ self._max_retries = max_retries
57
+
58
+ self._handlers: dict[str, Callable] = {}
59
+ self._wildcards: list[
60
+ tuple[str, Callable]
61
+ ] = [] # (prefix, handler) pre-sliced at registration
62
+ self._dedup = DedupCache()
63
+ self._backoff = ExponentialBackoff()
64
+ self._running = False
65
+ self._loop: asyncio.AbstractEventLoop | None = None
66
+ self._task: asyncio.Task | None = None
67
+ # Derived once from the raw api_key — mirrors what the ingest service stores at provisioning.
68
+ # Used to verify every event's HMAC before the handler is invoked.
69
+ self._signing_key: bytes = hmac.new(
70
+ api_key.encode(), b"eventhook-signing-v1", hashlib.sha256
71
+ ).digest()
72
+
73
+ # ── Handler registration ──────────────────────────────────────────────────
74
+
75
+ def on(self, event_type: str) -> Callable:
76
+ """
77
+ Register a handler for an event type. Supports exact match and
78
+ prefix wildcard (e.g. "invoice.*").
79
+
80
+ The decorated function may be sync or async. Sync handlers run in a
81
+ thread pool so they never block the event loop.
82
+
83
+ @hooks.on("payment.succeeded")
84
+ def handle(event: Event) -> Ack:
85
+ ...
86
+ return Ack.SUCCESS
87
+
88
+ @hooks.on("invoice.*")
89
+ async def handle(event: Event) -> Ack:
90
+ await some_coroutine()
91
+ return Ack.RETRY
92
+ """
93
+
94
+ def decorator(fn: Callable) -> Callable:
95
+ if event_type.endswith(".*"):
96
+ self._wildcards.append((event_type[:-1], fn))
97
+ else:
98
+ self._handlers[event_type] = fn
99
+ return fn
100
+
101
+ return decorator
102
+
103
+ # ── Entry points ──────────────────────────────────────────────────────────
104
+
105
+ def run(self) -> None:
106
+ """
107
+ Blocking entry point — creates its own event loop.
108
+ Use this for standalone scripts and Django management commands.
109
+
110
+ if __name__ == "__main__":
111
+ hooks.run()
112
+ """
113
+ loop = asyncio.new_event_loop()
114
+ asyncio.set_event_loop(loop)
115
+ self._run_loop(loop)
116
+
117
+ def run_in_thread(self) -> threading.Thread:
118
+ """
119
+ Starts the agent in a daemon thread with its own event loop.
120
+ Returns immediately. Use this for Flask and sync Django in-process.
121
+
122
+ hooks.run_in_thread()
123
+ app.run(port=8000)
124
+
125
+ Call stop() then join() the returned thread to ensure the session is
126
+ fully torn down before starting another one on the same credentials.
127
+ """
128
+
129
+ def _target():
130
+ loop = asyncio.new_event_loop()
131
+ asyncio.set_event_loop(loop)
132
+ self._run_loop(loop)
133
+
134
+ t = threading.Thread(target=_target, daemon=True, name="eventhook")
135
+ t.start()
136
+ return t
137
+
138
+ def _run_loop(self, loop: asyncio.AbstractEventLoop) -> None:
139
+ """Run start() on loop, then drain any tasks left pending by gRPC
140
+ internals before closing — mirrors what asyncio.run() does internally.
141
+
142
+ Without the drain step, gRPC's _AioCall._handle_status_once_received
143
+ tasks outlive the stream cancellation and emit 'Task was destroyed but
144
+ it is pending!' when the loop closes.
145
+ """
146
+ self._loop = loop
147
+ try:
148
+ loop.run_until_complete(self.start())
149
+ except asyncio.CancelledError:
150
+ pass
151
+ finally:
152
+ try:
153
+ pending = asyncio.all_tasks(loop)
154
+ if pending:
155
+ for t in pending:
156
+ t.cancel()
157
+ loop.run_until_complete(
158
+ asyncio.gather(*pending, return_exceptions=True)
159
+ )
160
+ finally:
161
+ self._loop = None
162
+ loop.close()
163
+
164
+ async def start(self) -> None:
165
+ """
166
+ Async entry point — runs inside an existing event loop.
167
+ Use this for FastAPI lifespan, Quart, and other async frameworks.
168
+
169
+ # FastAPI
170
+ @asynccontextmanager
171
+ async def lifespan(app):
172
+ task = asyncio.create_task(hooks.start())
173
+ yield
174
+ hooks.stop()
175
+ await task
176
+ """
177
+ self._running = True
178
+ self._task = asyncio.current_task()
179
+ while self._running:
180
+ try:
181
+ await self._run_session()
182
+ except asyncio.CancelledError:
183
+ break
184
+ except Exception as exc:
185
+ if not self._running:
186
+ break
187
+ wait = self._backoff.next()
188
+ logger.warning("Disconnected (%s) — reconnecting in %.1fs", exc, wait)
189
+ await asyncio.sleep(wait)
190
+
191
+ def stop(self) -> None:
192
+ """Stop the agent. Cancels the running session immediately."""
193
+ self._running = False
194
+ loop, task = self._loop, self._task
195
+ if loop is not None and task is not None:
196
+ loop.call_soon_threadsafe(task.cancel)
197
+
198
+ # ── Session lifecycle ─────────────────────────────────────────────────────
199
+
200
+ async def _run_session(self) -> None:
201
+ creds = (
202
+ grpc.ssl_channel_credentials()
203
+ if self._tls
204
+ else grpc.local_channel_credentials()
205
+ )
206
+ channel_options = _KEEPALIVE_OPTIONS
207
+
208
+ async with grpc.aio.secure_channel(
209
+ self._gateway_url, creds, options=channel_options
210
+ ) as channel:
211
+ stub = pb_grpc.EventBridgeStub(channel)
212
+ metadata = [
213
+ ("api_key", self._api_key),
214
+ ("provider_id", self._provider_id),
215
+ ("app_name", self._app_name),
216
+ ]
217
+ stream = stub.Subscribe(metadata=metadata)
218
+
219
+ all_subscribed = list(self._handlers.keys()) + [
220
+ p + "*" for p, _ in self._wildcards
221
+ ]
222
+ await stream.write(
223
+ pb.ClientMessage(
224
+ subscribe=pb.SubscribeRequest(event_types=all_subscribed)
225
+ )
226
+ )
227
+
228
+ self._backoff.reset()
229
+ logger.info(
230
+ "Connected to %s — subscribed to %s",
231
+ self._gateway_url,
232
+ all_subscribed or ["(all)"],
233
+ )
234
+
235
+ while True:
236
+ proto_event = await stream.read()
237
+ if proto_event == grpc.aio.EOF:
238
+ logger.info("Stream closed by gateway")
239
+ break
240
+
241
+ sig_header = proto_event.metadata.get("eh-sig", "")
242
+ if not self._verify_signature(sig_header, proto_event.payload):
243
+ logger.warning(
244
+ "Signature verification failed for event %s — dead-lettering",
245
+ proto_event.event_id,
246
+ )
247
+ await stream.write(
248
+ pb.ClientMessage(
249
+ ack=pb.Ack(
250
+ event_id=proto_event.event_id,
251
+ status=pb.Ack.Status.Value("DEAD_LETTERED"),
252
+ )
253
+ )
254
+ )
255
+ continue
256
+
257
+ event = Event._from_proto(proto_event)
258
+ ack = await self._dispatch(event)
259
+ proto_status = _ACK_STATUS[ack]
260
+
261
+ await stream.write(
262
+ pb.ClientMessage(
263
+ ack=pb.Ack(
264
+ event_id=event.event_id,
265
+ status=proto_status,
266
+ )
267
+ )
268
+ )
269
+
270
+ # Signature verification
271
+ def _verify_signature(self, header: str, payload: bytes) -> bool:
272
+ """Return True only if header carries a fresh, valid HMAC for payload."""
273
+ if not header:
274
+ return False
275
+ try:
276
+ parts = dict(kv.split("=", 1) for kv in header.split(","))
277
+ ts = int(parts["t"])
278
+ v1 = parts["v1"]
279
+ except (KeyError, ValueError):
280
+ return False
281
+
282
+ # Reject signatures outside the 5-minute replay window.
283
+ # Use the timestamp from the header (not event.timestamp) because it is
284
+ # the value that was included in the signed content.
285
+ age_ms = time.time() * 1000 - ts
286
+ if not (0 <= age_ms <= 5 * 60 * 1000):
287
+ return False
288
+
289
+ to_sign = f"{ts}".encode() + b"." + payload
290
+ expected = hmac.new(self._signing_key, to_sign, hashlib.sha256).hexdigest()
291
+ return hmac.compare_digest(expected, v1)
292
+
293
+ # Execute handler for event
294
+
295
+ async def _dispatch(self, event: Event) -> Ack:
296
+ if self._dedup.seen(event.event_id):
297
+ logger.debug("Duplicate event %s — skipping", event.event_id)
298
+ return Ack.SUCCESS
299
+
300
+ handler = self._match_handler(event.event_type)
301
+ if handler is None:
302
+ logger.warning(
303
+ "No handler for '%s' (event %s) — dead lettering",
304
+ event.event_type,
305
+ event.event_id,
306
+ )
307
+ return Ack.FAILED
308
+
309
+ try:
310
+ result = (
311
+ await handler(event)
312
+ if inspect.iscoroutinefunction(handler)
313
+ else await asyncio.get_running_loop().run_in_executor(
314
+ None, handler, event
315
+ )
316
+ )
317
+ if not isinstance(result, Ack):
318
+ raise TypeError(f"Handler must return Ack, got {type(result).__name__}")
319
+ except Exception as exc:
320
+ logger.error(
321
+ "Handler for '%s' raised %s: %s",
322
+ event.event_type,
323
+ type(exc).__name__,
324
+ exc,
325
+ )
326
+ return self._schedule_retry(event)
327
+
328
+ if result == Ack.SUCCESS:
329
+ return Ack.SUCCESS
330
+
331
+ if result == Ack.FAILED:
332
+ return Ack.FAILED
333
+
334
+ # Ack.RETRY returned directly (internal path) — same retry budget as exception.
335
+ return self._schedule_retry(event)
336
+
337
+ def _schedule_retry(self, event: Event) -> Ack:
338
+ # Durable retry tracking
339
+ attempt = int(event.metadata.get("eh-retry-count", "0"))
340
+ if attempt < self._max_retries:
341
+ self._dedup.evict(
342
+ event.event_id
343
+ ) # allow the retry delivery to reach the handler
344
+ logger.warning(
345
+ "Handler failed for '%s' (attempt %d/%d) — retrying",
346
+ event.event_type,
347
+ attempt + 1,
348
+ self._max_retries,
349
+ )
350
+ return Ack.RETRY
351
+ logger.error(
352
+ "Handler failed for '%s' after %d attempts — dead lettering",
353
+ event.event_type,
354
+ attempt + 1,
355
+ )
356
+ return Ack.FAILED
357
+
358
+ def _match_handler(self, event_type: str) -> Callable | None:
359
+ if event_type in self._handlers:
360
+ return self._handlers[event_type]
361
+ for prefix, handler in self._wildcards:
362
+ if event_type.startswith(prefix):
363
+ return handler
364
+ return None
@@ -0,0 +1,29 @@
1
+ import time
2
+ from collections.abc import Callable
3
+ from cachetools import TTLCache
4
+
5
+
6
+ class DedupCache:
7
+ """
8
+ Deduplicates events by event_id using an LRU+TTL cache.
9
+ Protects against at-least-once redelivery reaching the handler twice.
10
+ """
11
+
12
+ def __init__(
13
+ self,
14
+ maxsize: int = 2000,
15
+ ttl: float = 300.0,
16
+ timer: Callable[[], float] = time.monotonic,
17
+ ) -> None:
18
+ self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl, timer=timer)
19
+
20
+ def seen(self, event_id: str) -> bool:
21
+ """Return True if this event_id was already seen (duplicate). Records it if not."""
22
+ if event_id in self._cache:
23
+ return True
24
+ self._cache[event_id] = True
25
+ return False
26
+
27
+ def evict(self, event_id: str) -> None:
28
+ """Remove event_id so a retry can reach the handler."""
29
+ self._cache.pop(event_id, None)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from enum import IntEnum
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Event:
11
+ event_id: str
12
+ event_type: str
13
+ payload: Any # decoded from JSON bytes
14
+ timestamp: int # unix ms
15
+ metadata: dict[str, str]
16
+
17
+ @classmethod
18
+ def _from_proto(cls, pb) -> "Event":
19
+ return cls(
20
+ event_id=pb.event_id,
21
+ event_type=pb.event_type,
22
+ payload=json.loads(pb.payload) if pb.payload else None,
23
+ timestamp=pb.timestamp,
24
+ metadata=dict(pb.metadata),
25
+ )
26
+
27
+
28
+ class Ack(IntEnum):
29
+ SUCCESS = 0 # handler completed — event acknowledged
30
+ FAILED = 1 # permanent failure — event dead-lettered, no retry
31
+ RETRY = 2 # internal: emitted by the SDK daemon on exception/stall, not for handler authors
File without changes
@@ -0,0 +1,51 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: eventbridge/eventbridge.proto
5
+ # Protobuf Python Version: 6.33.5
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 33,
16
+ 5,
17
+ '',
18
+ 'eventbridge/eventbridge.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x65ventbridge/eventbridge.proto\x12\x0b\x65ventbridge\"l\n\rClientMessage\x12\x32\n\tsubscribe\x18\x01 \x01(\x0b\x32\x1d.eventbridge.SubscribeRequestH\x00\x12\x1f\n\x03\x61\x63k\x18\x02 \x01(\x0b\x32\x10.eventbridge.AckH\x00\x42\x06\n\x04\x62ody\"\'\n\x10SubscribeRequest\x12\x13\n\x0b\x65vent_types\x18\x01 \x03(\t\"\xb6\x01\n\x05\x45vent\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\x12\n\nevent_type\x18\x02 \x01(\t\x12\x0f\n\x07payload\x18\x03 \x01(\x0c\x12\x11\n\ttimestamp\x18\x04 \x01(\x03\x12\x32\n\x08metadata\x18\x05 \x03(\x0b\x32 .eventbridge.Event.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"z\n\x03\x41\x63k\x12\x10\n\x08\x65vent_id\x18\x01 \x01(\t\x12\'\n\x06status\x18\x02 \x01(\x0e\x32\x17.eventbridge.Ack.Status\"8\n\x06Status\x12\r\n\tDELIVERED\x10\x00\x12\x0c\n\x08RETRYING\x10\x01\x12\x11\n\rDEAD_LETTERED\x10\x02\x32N\n\x0b\x45ventBridge\x12?\n\tSubscribe\x12\x1a.eventbridge.ClientMessage\x1a\x12.eventbridge.Event(\x01\x30\x01\x42\x35Z3github.com/eventhook/stream-gateway/gen/eventbridgeb\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'eventbridge.eventbridge_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ _globals['DESCRIPTOR']._loaded_options = None
34
+ _globals['DESCRIPTOR']._serialized_options = b'Z3github.com/eventhook/stream-gateway/gen/eventbridge'
35
+ _globals['_EVENT_METADATAENTRY']._loaded_options = None
36
+ _globals['_EVENT_METADATAENTRY']._serialized_options = b'8\001'
37
+ _globals['_CLIENTMESSAGE']._serialized_start=46
38
+ _globals['_CLIENTMESSAGE']._serialized_end=154
39
+ _globals['_SUBSCRIBEREQUEST']._serialized_start=156
40
+ _globals['_SUBSCRIBEREQUEST']._serialized_end=195
41
+ _globals['_EVENT']._serialized_start=198
42
+ _globals['_EVENT']._serialized_end=380
43
+ _globals['_EVENT_METADATAENTRY']._serialized_start=333
44
+ _globals['_EVENT_METADATAENTRY']._serialized_end=380
45
+ _globals['_ACK']._serialized_start=382
46
+ _globals['_ACK']._serialized_end=504
47
+ _globals['_ACK_STATUS']._serialized_start=448
48
+ _globals['_ACK_STATUS']._serialized_end=504
49
+ _globals['_EVENTBRIDGE']._serialized_start=506
50
+ _globals['_EVENTBRIDGE']._serialized_end=584
51
+ # @@protoc_insertion_point(module_scope)
@@ -0,0 +1,97 @@
1
+ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
2
+ """Client and server classes corresponding to protobuf-defined services."""
3
+ import grpc
4
+ import warnings
5
+
6
+ from . import eventbridge_pb2 as eventbridge_dot_eventbridge__pb2
7
+
8
+ GRPC_GENERATED_VERSION = '1.81.0'
9
+ GRPC_VERSION = grpc.__version__
10
+ _version_not_supported = False
11
+
12
+ try:
13
+ from grpc._utilities import first_version_is_lower
14
+ _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
15
+ except ImportError:
16
+ _version_not_supported = True
17
+
18
+ if _version_not_supported:
19
+ raise RuntimeError(
20
+ f'The grpc package installed is at version {GRPC_VERSION},'
21
+ + ' but the generated code in eventbridge/eventbridge_pb2_grpc.py depends on'
22
+ + f' grpcio>={GRPC_GENERATED_VERSION}.'
23
+ + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
24
+ + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
25
+ )
26
+
27
+
28
+ class EventBridgeStub:
29
+ """Missing associated documentation comment in .proto file."""
30
+
31
+ def __init__(self, channel):
32
+ """Constructor.
33
+
34
+ Args:
35
+ channel: A grpc.Channel.
36
+ """
37
+ self.Subscribe = channel.stream_stream(
38
+ '/eventbridge.EventBridge/Subscribe',
39
+ request_serializer=eventbridge_dot_eventbridge__pb2.ClientMessage.SerializeToString,
40
+ response_deserializer=eventbridge_dot_eventbridge__pb2.Event.FromString,
41
+ _registered_method=True)
42
+
43
+
44
+ class EventBridgeServicer:
45
+ """Missing associated documentation comment in .proto file."""
46
+
47
+ def Subscribe(self, request_iterator, context):
48
+ """Missing associated documentation comment in .proto file."""
49
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
50
+ context.set_details('Method not implemented!')
51
+ raise NotImplementedError('Method not implemented!')
52
+
53
+
54
+ def add_EventBridgeServicer_to_server(servicer, server):
55
+ rpc_method_handlers = {
56
+ 'Subscribe': grpc.stream_stream_rpc_method_handler(
57
+ servicer.Subscribe,
58
+ request_deserializer=eventbridge_dot_eventbridge__pb2.ClientMessage.FromString,
59
+ response_serializer=eventbridge_dot_eventbridge__pb2.Event.SerializeToString,
60
+ ),
61
+ }
62
+ generic_handler = grpc.method_handlers_generic_handler(
63
+ 'eventbridge.EventBridge', rpc_method_handlers)
64
+ server.add_generic_rpc_handlers((generic_handler,))
65
+ server.add_registered_method_handlers('eventbridge.EventBridge', rpc_method_handlers)
66
+
67
+
68
+ # This class is part of an EXPERIMENTAL API.
69
+ class EventBridge:
70
+ """Missing associated documentation comment in .proto file."""
71
+
72
+ @staticmethod
73
+ def Subscribe(request_iterator,
74
+ target,
75
+ options=(),
76
+ channel_credentials=None,
77
+ call_credentials=None,
78
+ insecure=False,
79
+ compression=None,
80
+ wait_for_ready=None,
81
+ timeout=None,
82
+ metadata=None):
83
+ return grpc.experimental.stream_stream(
84
+ request_iterator,
85
+ target,
86
+ '/eventbridge.EventBridge/Subscribe',
87
+ eventbridge_dot_eventbridge__pb2.ClientMessage.SerializeToString,
88
+ eventbridge_dot_eventbridge__pb2.Event.FromString,
89
+ options,
90
+ channel_credentials,
91
+ insecure,
92
+ call_credentials,
93
+ compression,
94
+ wait_for_ready,
95
+ timeout,
96
+ metadata,
97
+ _registered_method=True)
File without changes
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "eventhook"
3
+ version = "0.1.0"
4
+ description = "Receive provider events in your app without exposing a public endpoint"
5
+ requires-python = ">=3.11"
6
+ license = { text = "MIT" }
7
+ keywords = ["webhooks", "events", "grpc", "real-time", "event-driven"]
8
+ classifiers = [
9
+ "Development Status :: 3 - Alpha",
10
+ "Intended Audience :: Developers",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Operating System :: OS Independent",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Topic :: Internet",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "grpcio>=1.60.0,<3",
23
+ "protobuf>=6.33.5,<7",
24
+ "cachetools>=5.0,<6",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://eventhook.dev"
29
+ Repository = "https://github.com/iyifr/eventhook"
30
+ Issues = "https://github.com/iyifr/eventhook/issues"
31
+
32
+ [dependency-groups]
33
+ dev = [
34
+ "grpcio-tools>=1.60.0,<3",
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.23",
37
+ ]
38
+
39
+ [build-system]
40
+ requires = ["hatchling"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.build]
44
+ artifacts = ["eventhook/_proto/**"]
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ include = ["eventhook/**"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ include = [
51
+ "/eventhook",
52
+ "/pyproject.toml",
53
+ ]
54
+
55
+ [tool.pytest.ini_options]
56
+ asyncio_mode = "auto"