linkbridge 0.1.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.
linkbridge/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Linkbridge Python SDK — public surface.
2
+
3
+ Stable imports for downstream callers; everything else is implementation
4
+ detail and may change without a major version bump.
5
+ """
6
+
7
+ from .client import LinkbridgeClient, SDK_VERSION
8
+ from .errors import APIError, SignatureError
9
+ from .webhook import (
10
+ MAX_WEBHOOK_SKEW_SECONDS,
11
+ SIGNATURE_HEADER,
12
+ verify_webhook,
13
+ )
14
+
15
+ __all__ = [
16
+ "LinkbridgeClient",
17
+ "APIError",
18
+ "SignatureError",
19
+ "verify_webhook",
20
+ "SIGNATURE_HEADER",
21
+ "MAX_WEBHOOK_SKEW_SECONDS",
22
+ "SDK_VERSION",
23
+ ]
24
+
25
+ __version__ = SDK_VERSION
linkbridge/client.py ADDED
@@ -0,0 +1,322 @@
1
+ """LinkbridgeClient — the SDK entry point.
2
+
3
+ Design notes
4
+ ------------
5
+
6
+ * Stdlib-only HTTP via ``urllib.request``. We deliberately avoid the
7
+ ``requests``/``httpx`` dependency so the package installs cleanly
8
+ into AWS Lambda layers and air-gapped POS firmware.
9
+ * OAuth2 client-credentials flow with lazy, thread-safe token refresh.
10
+ Tokens are refreshed 60 seconds before expiry; concurrent callers
11
+ block on a single in-flight refresh via a threading.Lock.
12
+ * Mirrors the resource grouping of the Node and Go SDKs:
13
+ ``client.invoices.{submit, get, list, transmit, update_status}``,
14
+ ``client.webhooks.{create, list, delete}``, ``client.lookups.*``.
15
+ * The ``transport`` constructor argument lets tests inject a fake
16
+ request handler (see ``tests/test_client.py``).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import secrets
23
+ import threading
24
+ import time
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple
29
+
30
+ from .errors import APIError
31
+
32
+ SDK_VERSION = "0.1.0"
33
+
34
+ # A Transport is a pluggable HTTP function. Returning the tuple
35
+ # (status, headers, body_bytes) keeps the interface trivially mockable.
36
+ Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, Mapping[str, str], bytes]]
37
+
38
+
39
+ def _stdlib_transport(
40
+ method: str,
41
+ url: str,
42
+ headers: Mapping[str, str],
43
+ body: Optional[bytes],
44
+ ) -> Tuple[int, Mapping[str, str], bytes]:
45
+ """Default transport built on urllib. Captures error responses so
46
+ callers (and ``APIError.from_response``) can decode the body even
47
+ on 4xx/5xx — urllib normally treats those as exceptions."""
48
+ req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — trusted scheme
51
+ return resp.status, dict(resp.headers), resp.read()
52
+ except urllib.error.HTTPError as exc:
53
+ return exc.code, dict(exc.headers or {}), exc.read()
54
+
55
+
56
+ class LinkbridgeClient:
57
+ """Synchronous Linkbridge API client."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ base_url: str,
63
+ client_id: Optional[str] = None,
64
+ client_secret: Optional[str] = None,
65
+ static_token: Optional[str] = None,
66
+ scopes: Optional[Iterable[str]] = None,
67
+ user_agent: Optional[str] = None,
68
+ transport: Optional[Transport] = None,
69
+ ) -> None:
70
+ if not base_url:
71
+ raise ValueError("linkbridge: base_url is required")
72
+ if not static_token and not (client_id and client_secret):
73
+ raise ValueError(
74
+ "linkbridge: either static_token or client_id+client_secret is required"
75
+ )
76
+
77
+ self._base_url = base_url.rstrip("/")
78
+ self._client_id = client_id
79
+ self._client_secret = client_secret
80
+ self._static_token = static_token
81
+ self._scopes = list(scopes) if scopes else ["invoices:write", "invoices:read"]
82
+ self._user_agent_suffix = user_agent or ""
83
+ self._transport: Transport = transport or _stdlib_transport
84
+
85
+ self._token_cache: Optional[Tuple[str, float]] = None # (token, expires_at_epoch)
86
+ self._token_lock = threading.Lock()
87
+
88
+ self.invoices = InvoicesAPI(self)
89
+ self.webhooks = WebhooksAPI(self)
90
+ self.lookups = LookupsAPI(self)
91
+
92
+ # ----- public helpers -------------------------------------------------
93
+
94
+ @staticmethod
95
+ def idempotency_key() -> str:
96
+ """Return a fresh URL-safe Idempotency-Key (32 hex chars + prefix)."""
97
+ return "lb-" + secrets.token_hex(16)
98
+
99
+ def user_agent(self) -> str:
100
+ base = f"linkbridge-python/{SDK_VERSION}"
101
+ return f"{base} {self._user_agent_suffix}".rstrip()
102
+
103
+ # ----- token management ----------------------------------------------
104
+
105
+ def _token(self) -> str:
106
+ if self._static_token:
107
+ return self._static_token
108
+ with self._token_lock:
109
+ if self._token_cache and self._token_cache[1] - time.time() > 60:
110
+ return self._token_cache[0]
111
+ self._refresh_token_locked()
112
+ assert self._token_cache is not None
113
+ return self._token_cache[0]
114
+
115
+ def _refresh_token_locked(self) -> None:
116
+ body = json.dumps(
117
+ {
118
+ "client_id": self._client_id,
119
+ "client_secret": self._client_secret,
120
+ "grant_type": "client_credentials",
121
+ "scope": " ".join(self._scopes),
122
+ }
123
+ ).encode("utf-8")
124
+ status, _headers, raw = self._transport(
125
+ "POST",
126
+ f"{self._base_url}/v1/oauth/token",
127
+ {
128
+ "content-type": "application/json",
129
+ "accept": "application/json",
130
+ "user-agent": self.user_agent(),
131
+ },
132
+ body,
133
+ )
134
+ if status // 100 != 2:
135
+ raise APIError.from_response(status, raw)
136
+ try:
137
+ decoded = json.loads(raw)
138
+ except ValueError as exc:
139
+ raise APIError(
140
+ status=status,
141
+ code="invalid_token_response",
142
+ message="non-JSON token response",
143
+ raw=raw,
144
+ ) from exc
145
+ token = decoded.get("access_token")
146
+ ttl = int(decoded.get("expires_in") or 300)
147
+ if not token:
148
+ raise APIError(
149
+ status=status,
150
+ code="invalid_token_response",
151
+ message="empty access_token",
152
+ raw=raw,
153
+ )
154
+ self._token_cache = (token, time.time() + ttl)
155
+
156
+ # ----- request plumbing ----------------------------------------------
157
+
158
+ def request(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ query: Optional[Mapping[str, Any]] = None,
164
+ body: Any = None,
165
+ extra_headers: Optional[Mapping[str, str]] = None,
166
+ expect_json: bool = True,
167
+ ) -> Any:
168
+ """Authenticated request. Returns the decoded JSON body on
169
+ 2xx (or ``None`` for 204 / empty bodies). Raises :class:`APIError`
170
+ otherwise."""
171
+ url = self._base_url + path
172
+ if query:
173
+ cleaned = {k: str(v) for k, v in query.items() if v is not None and v != ""}
174
+ if cleaned:
175
+ url = f"{url}?{urllib.parse.urlencode(cleaned)}"
176
+
177
+ headers: Dict[str, str] = {
178
+ "authorization": "Bearer " + self._token(),
179
+ "accept": "application/json",
180
+ "user-agent": self.user_agent(),
181
+ }
182
+ raw_body: Optional[bytes] = None
183
+ if body is not None:
184
+ headers["content-type"] = "application/json"
185
+ raw_body = json.dumps(body).encode("utf-8")
186
+ if extra_headers:
187
+ for k, v in extra_headers.items():
188
+ headers[k.lower()] = v
189
+
190
+ status, _resp_headers, payload = self._transport(method, url, headers, raw_body)
191
+ if status // 100 != 2:
192
+ raise APIError.from_response(status, payload)
193
+ if not expect_json or status == 204 or not payload:
194
+ return None
195
+ try:
196
+ return json.loads(payload)
197
+ except ValueError as exc:
198
+ raise APIError(
199
+ status=status,
200
+ code="invalid_json_response",
201
+ message="server returned non-JSON body",
202
+ raw=payload,
203
+ ) from exc
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Resource handles. Each one is a thin facade over LinkbridgeClient.request
208
+ # so callers get IDE-friendly grouping (`client.invoices.submit(...)`).
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ class InvoicesAPI:
213
+ def __init__(self, client: LinkbridgeClient) -> None:
214
+ self._c = client
215
+
216
+ def submit(
217
+ self,
218
+ invoice: Mapping[str, Any],
219
+ *,
220
+ idempotency_key: Optional[str] = None,
221
+ mode: Optional[str] = None,
222
+ ) -> Dict[str, Any]:
223
+ """POST /v1/invoices. Returns the InvoiceAccepted envelope."""
224
+ headers = {
225
+ "idempotency-key": idempotency_key or LinkbridgeClient.idempotency_key(),
226
+ }
227
+ return self._c.request(
228
+ "POST",
229
+ "/v1/invoices",
230
+ query={"mode": mode} if mode else None,
231
+ body=invoice,
232
+ extra_headers=headers,
233
+ )
234
+
235
+ def get(self, irn: str) -> Dict[str, Any]:
236
+ """GET /v1/invoices/{irn}."""
237
+ return self._c.request("GET", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}")
238
+
239
+ def list(
240
+ self,
241
+ *,
242
+ cursor: Optional[str] = None,
243
+ limit: Optional[int] = None,
244
+ status: Optional[str] = None,
245
+ ) -> Dict[str, Any]:
246
+ """GET /v1/invoices. Returns ``{"data": [...], "next_cursor": ...}``."""
247
+ return self._c.request(
248
+ "GET",
249
+ "/v1/invoices",
250
+ query={"cursor": cursor, "limit": limit, "status": status},
251
+ )
252
+
253
+ def transmit(self, irn: str) -> Dict[str, Any]:
254
+ """POST /v1/invoices/{irn}/transmit — re-queue for transmission."""
255
+ return self._c.request(
256
+ "POST", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/transmit"
257
+ )
258
+
259
+ def update_status(
260
+ self,
261
+ irn: str,
262
+ *,
263
+ payment_status: str,
264
+ reference: Optional[str] = None,
265
+ ) -> Dict[str, Any]:
266
+ """POST /v1/invoices/{irn}/status — record a payment-state change."""
267
+ body: Dict[str, Any] = {"payment_status": payment_status}
268
+ if reference is not None:
269
+ body["reference"] = reference
270
+ return self._c.request(
271
+ "POST",
272
+ f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/status",
273
+ body=body,
274
+ )
275
+
276
+
277
+ class WebhooksAPI:
278
+ def __init__(self, client: LinkbridgeClient) -> None:
279
+ self._c = client
280
+
281
+ def create(
282
+ self,
283
+ *,
284
+ url: str,
285
+ events: Iterable[str],
286
+ description: Optional[str] = None,
287
+ ) -> Dict[str, Any]:
288
+ """POST /v1/webhooks. The plaintext ``secret`` is returned on
289
+ the response and never again — store it securely."""
290
+ body: Dict[str, Any] = {"url": url, "events": list(events)}
291
+ if description is not None:
292
+ body["description"] = description
293
+ return self._c.request("POST", "/v1/webhooks", body=body)
294
+
295
+ def list(self) -> Dict[str, Any]:
296
+ """GET /v1/webhooks."""
297
+ return self._c.request("GET", "/v1/webhooks")
298
+
299
+ def delete(self, webhook_id: str) -> None:
300
+ """DELETE /v1/webhooks/{id}."""
301
+ self._c.request(
302
+ "DELETE",
303
+ f"/v1/webhooks/{urllib.parse.quote(webhook_id, safe='')}",
304
+ expect_json=False,
305
+ )
306
+
307
+
308
+ class LookupsAPI:
309
+ def __init__(self, client: LinkbridgeClient) -> None:
310
+ self._c = client
311
+
312
+ def tax_codes(self) -> Dict[str, Any]:
313
+ """GET /v1/lookups/tax-codes."""
314
+ return self._c.request("GET", "/v1/lookups/tax-codes")
315
+
316
+ def hsn_codes(self, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
317
+ """GET /v1/lookups/hsn-codes (paginated)."""
318
+ return self._c.request(
319
+ "GET",
320
+ "/v1/lookups/hsn-codes",
321
+ query={"limit": limit, "cursor": cursor},
322
+ )
linkbridge/errors.py ADDED
@@ -0,0 +1,66 @@
1
+ """Error types for the Linkbridge Python SDK.
2
+
3
+ The API always returns a `{"error": {"code", "message", "trace_id"}}`
4
+ envelope on failure. We surface those fields verbatim on `APIError` so
5
+ callers can switch on the canonical code without parsing the message.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any, Mapping, Optional
12
+
13
+
14
+ class APIError(Exception):
15
+ """Raised for any non-2xx HTTP response from the Linkbridge API."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ status: int,
21
+ code: str,
22
+ message: str,
23
+ trace_id: Optional[str] = None,
24
+ details: Optional[Any] = None,
25
+ raw: Optional[bytes] = None,
26
+ ) -> None:
27
+ super().__init__(f"linkbridge: {status} {code}: {message}")
28
+ self.status = status
29
+ self.code = code
30
+ self.message = message
31
+ self.trace_id = trace_id
32
+ self.details = details
33
+ self.raw = raw
34
+
35
+ @classmethod
36
+ def from_response(cls, status: int, body: bytes) -> "APIError":
37
+ """Best-effort decoder. Falls back to opaque codes when the
38
+ response is not the canonical error envelope (e.g. a 502 from a
39
+ load balancer that has no idea what an APIError looks like)."""
40
+ code = "http_error"
41
+ message = body.decode("utf-8", errors="replace")[:500] or f"http {status}"
42
+ trace_id: Optional[str] = None
43
+ details: Any = None
44
+ try:
45
+ decoded = json.loads(body)
46
+ except (ValueError, UnicodeDecodeError):
47
+ decoded = None
48
+ if isinstance(decoded, Mapping):
49
+ err = decoded.get("error")
50
+ if isinstance(err, Mapping):
51
+ code = str(err.get("code") or code)
52
+ message = str(err.get("message") or message)
53
+ trace_id = err.get("trace_id")
54
+ details = err.get("details")
55
+ return cls(
56
+ status=status,
57
+ code=code,
58
+ message=message,
59
+ trace_id=trace_id,
60
+ details=details,
61
+ raw=body,
62
+ )
63
+
64
+
65
+ class SignatureError(Exception):
66
+ """Raised by ``verify_webhook`` when a delivery cannot be trusted."""
linkbridge/webhook.py ADDED
@@ -0,0 +1,97 @@
1
+ """HMAC-SHA256 verification for Linkbridge webhook deliveries.
2
+
3
+ Spec contract (see openapi.yaml §security and ADR-0006):
4
+
5
+ X-Linkbridge-Signature: t=<unix>,v1=<hex>
6
+ v1 = hex(HMAC-SHA256(secret, f"{t}.{body}"))
7
+
8
+ The receiver MUST reject deliveries whose timestamp falls outside a
9
+ 5-minute window to bound replay-attack horizons. We mirror the same
10
+ constant-time comparison logic the API server uses.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hmac
16
+ import time
17
+ from hashlib import sha256
18
+ from typing import Optional
19
+
20
+ from .errors import SignatureError
21
+
22
+ SIGNATURE_HEADER = "X-Linkbridge-Signature"
23
+ MAX_WEBHOOK_SKEW_SECONDS = 5 * 60
24
+
25
+
26
+ def verify_webhook(
27
+ *,
28
+ secret: bytes,
29
+ body: bytes,
30
+ header: str,
31
+ now: Optional[float] = None,
32
+ ) -> None:
33
+ """Verify a webhook signature. Raises ``SignatureError`` on any
34
+ failure mode (missing/malformed header, replay-window violation,
35
+ signature mismatch). Returns ``None`` on success.
36
+
37
+ Parameters
38
+ ----------
39
+ secret:
40
+ The raw webhook secret bytes returned by ``POST /v1/webhooks``.
41
+ Do NOT pass the secret as ``str`` — Python would otherwise
42
+ encode it implicitly with the platform's default encoding.
43
+ body:
44
+ The exact request body bytes as read off the wire. JSON
45
+ re-serialisation will alter byte ordering and break verification.
46
+ header:
47
+ The raw value of the ``X-Linkbridge-Signature`` header.
48
+ now:
49
+ Override the current time (epoch seconds). Production code
50
+ should leave this as ``None`` so we use ``time.time()``.
51
+ """
52
+ if not header:
53
+ raise SignatureError("missing X-Linkbridge-Signature")
54
+ if not isinstance(secret, (bytes, bytearray)):
55
+ raise TypeError("secret must be bytes; pass secret.encode() if it's a str")
56
+ if not isinstance(body, (bytes, bytearray)):
57
+ raise TypeError("body must be bytes; do not pre-decode the request body")
58
+
59
+ t, sig = _parse_header(header)
60
+ current = time.time() if now is None else now
61
+ if abs(current - t) > MAX_WEBHOOK_SKEW_SECONDS:
62
+ raise SignatureError("signature timestamp outside replay window")
63
+
64
+ expected = hmac.new(secret, f"{t}.".encode("ascii"), sha256)
65
+ expected.update(body)
66
+ expected_hex = expected.hexdigest()
67
+ # hmac.compare_digest is constant-time; explicit lower() avoids
68
+ # accidentally rejecting a server that uppercased the hex.
69
+ if not hmac.compare_digest(expected_hex, sig.lower()):
70
+ raise SignatureError("signature mismatch")
71
+
72
+
73
+ def _parse_header(header: str) -> tuple[int, str]:
74
+ """Parse ``t=<unix>,v1=<hex>`` into ``(timestamp_int, hex_str)``.
75
+
76
+ Raises ``SignatureError`` for any structural problem.
77
+ """
78
+ t: Optional[int] = None
79
+ sig: Optional[str] = None
80
+ for part in header.split(","):
81
+ kv = part.strip().split("=", 1)
82
+ if len(kv) != 2:
83
+ raise SignatureError("malformed signature header")
84
+ key, value = kv[0], kv[1]
85
+ if key == "t":
86
+ try:
87
+ t = int(value)
88
+ except ValueError as exc:
89
+ raise SignatureError("malformed signature timestamp") from exc
90
+ if t <= 0:
91
+ raise SignatureError("malformed signature timestamp")
92
+ elif key == "v1":
93
+ sig = value
94
+ # Unknown keys are tolerated for forward-compat (e.g. v2=).
95
+ if t is None or not sig:
96
+ raise SignatureError("malformed signature header")
97
+ return t, sig
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkbridge
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Linkbridge e-invoicing API
5
+ Author: Linkbridge
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://docs.linkbridge.ng
8
+ Project-URL: Documentation, https://docs.linkbridge.ng
9
+ Project-URL: Source, https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python
10
+ Project-URL: Issues, https://github.com/Linkbridge-Systems/linkbridge-sdks/issues
11
+ Keywords: linkbridge,e-invoicing,nrs,fbr,fiscalisation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: test
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+ Requires-Dist: pytest-cov>=4; extra == "test"
23
+
24
+ # linkbridge (Python SDK)
25
+
26
+ Official Python client for the [Linkbridge](https://linkbridge.ng)
27
+ e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
28
+ [Node](../sdk-node) SDKs against the same OpenAPI contract
29
+ (`tools/openapi/openapi.yaml`).
30
+
31
+ * **Zero runtime dependencies** — uses only the Python standard library
32
+ so the package drops cleanly into Lambda layers, vendored POS
33
+ firmware, and air-gapped merchant ERPs.
34
+ * **Sync API** with explicit OAuth2 client-credentials handling and
35
+ automatic token refresh.
36
+ * **Webhook signature verification** with constant-time comparison and
37
+ the same 5-minute clock-skew window enforced server-side.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install linkbridge
43
+ ```
44
+
45
+ Requires Python 3.9 or newer.
46
+
47
+ ## Quickstart
48
+
49
+ ```python
50
+ from linkbridge import LinkbridgeClient
51
+
52
+ client = LinkbridgeClient(
53
+ base_url="https://api.linkbridge.ng",
54
+ client_id="your-client-id",
55
+ client_secret="your-client-secret",
56
+ scopes=["invoices:write", "invoices:read"],
57
+ )
58
+
59
+ accepted = client.invoices.submit({
60
+ "irn": "INV001-SVC01-20260601",
61
+ "invoice_kind": "Standard",
62
+ # …rest of the canonical NRS payload — see
63
+ # packages/schema/invoice.schema.json
64
+ })
65
+ print(accepted["irn"], accepted["status"])
66
+
67
+ # Read back, paginate, retry, mutate payment status:
68
+ record = client.invoices.get(accepted["irn"])
69
+ page = client.invoices.list(limit=20, status="failed")
70
+ requeued = client.invoices.transmit(accepted["irn"])
71
+ paid = client.invoices.update_status(accepted["irn"],
72
+ payment_status="PAID", reference="RCPT-001")
73
+ ```
74
+
75
+ ## Webhook verification
76
+
77
+ ```python
78
+ from linkbridge import verify_webhook, SignatureError
79
+
80
+ @app.post("/hooks/linkbridge")
81
+ def hook(request):
82
+ try:
83
+ verify_webhook(
84
+ secret=os.environ["WEBHOOK_SECRET"].encode(),
85
+ body=request.body, # raw bytes off the wire
86
+ header=request.headers["X-Linkbridge-Signature"],
87
+ )
88
+ except SignatureError:
89
+ return 401
90
+ # …handle the event
91
+ ```
92
+
93
+ ## Errors
94
+
95
+ All non-2xx responses raise `linkbridge.APIError`, which exposes the
96
+ HTTP status, the canonical `error.code`, the human `error.message`, and
97
+ the `trace_id` so that operators can correlate against server logs.
98
+
99
+ ## Versioning
100
+
101
+ The package follows the same `0.MINOR.PATCH` cadence as the API surface
102
+ during the beta. Breaking changes will be confined to MINOR bumps until
103
+ the API freezes at `1.0.0`.
@@ -0,0 +1,8 @@
1
+ linkbridge/__init__.py,sha256=lw8ZIos96eouCrAZ2qZRo3WcJgfJwIOayaqn8SSs4sw,569
2
+ linkbridge/client.py,sha256=91pJ5602Ww3ZWm_4g4PiKDbC6lv1lcmCQ-gsdq6z4wI,11326
3
+ linkbridge/errors.py,sha256=7h65rWbhJfS6kZIdfv0SBExek_dO5ygKdkkqB2R4vvE,2161
4
+ linkbridge/webhook.py,sha256=7jv66umJJaaZQuMmlghRsbpKieCNDLRWJhZmUcjTMXI,3466
5
+ linkbridge-0.1.0.dist-info/METADATA,sha256=WuBKXIUPDvw1OGjPmUgnSqzIArbcRnW4g3Dq2L4WMb8,3409
6
+ linkbridge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ linkbridge-0.1.0.dist-info/top_level.txt,sha256=ouOG4YXK5s5ePtI7mN8gRsY6YC5JEFpC8pyKu4Nq3as,11
8
+ linkbridge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ linkbridge