bn-shortener 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BroadNet Technologies LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.5
2
+ Name: bn-shortener
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the BN Shortener API (b1.lc): links, QR codes, bio pages, analytics, webhooks, OAuth
5
+ Project-URL: Homepage, https://b1.lc
6
+ Project-URL: Documentation, https://b1.lc/docs/
7
+ Project-URL: Source, https://github.com/BN-Shortner/bn-shortener-python
8
+ Project-URL: Issues, https://github.com/BN-Shortner/bn-shortener-python/issues
9
+ Author-email: BroadNet Technologies LLC <support@broadnet.me>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: b1.lc,broadnet,qr codes,short links,url shortener
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # bn-shortener (Python)
20
+
21
+ Official Python client for the [BN Shortener API](https://b1.lc/docs/) (b1.lc): short links, QR codes,
22
+ bio pages, analytics, Launch Check, webhooks and OAuth. No dependencies; Python 3.9+.
23
+
24
+ ```bash
25
+ pip install bn-shortener
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ from bn_shortener import BNShortener
32
+
33
+ bn = BNShortener("bn_live_...") # or set BN_API_KEY
34
+
35
+ link = bn.links.create(url="https://example.com/spring-sale", title="Spring sale")
36
+ print(link["short_url"]) # https://b1.lc/AbC9
37
+
38
+ stats = bn.links.stats(link["id"], period="30d")
39
+ print(stats["summary"]["total_clicks"])
40
+
41
+ for l in bn.paginate(bn.links.list, search="sale"): # every page
42
+ print(l["short_code"], l["click_count"])
43
+ ```
44
+
45
+ Create an API key in the dashboard: **API Portal → API keys**. A key has permissions (`read`, `write`, `delete`,
46
+ `webhooks`, `admin`); give each integration only what it needs.
47
+
48
+ ## Resources
49
+
50
+ | Attribute | What |
51
+ |---|---|
52
+ | `bn.links` | create, get, get_by_code, update, delete, archive, restore, clone, stats, clicks, referrers, devices, geo, timeline, export, import, bulk_create / bulk_update / bulk_delete, health, stream_clicks |
53
+ | `bn.qr_codes` | create, get, update, delete, list, image (png, jpeg, webp, svg, pdf, eps), stats |
54
+ | `bn.bio_pages` | create, get, update, delete, list, list_blocks, create_block, update_block, delete_block, reorder_blocks, analytics, subscribers, messages |
55
+ | `bn.webhooks` | create, get, update, delete, list, test, deliveries, get_delivery, redeliver, events |
56
+ | `bn.launch_check` | run, get, start_run, get_run, stop_run |
57
+ | `bn.security` | scan, check, get_scan, bulk_scan, list_scans, ... |
58
+ | `bn.messaging` | create_batch, list_batches, get_batch, list_messages, record_conversion |
59
+ | `bn.api_keys`, `bn.account`, `bn.app_links`, `bn.system` | keys, usage + audit log, apps, API info |
60
+
61
+ Every method's docstring names its endpoint and the permission it needs. Methods return the response's `data`;
62
+ list methods return a `Page` (iterate it; `page.pagination`, `page.next_page()`); files come back as
63
+ `BinaryResponse` (`.content`, `.content_type`, `.save(path)`):
64
+
65
+ ```python
66
+ qr = bn.qr_codes.create(name="Menu", content={"url": "https://example.com/menu"})
67
+ bn.qr_codes.image(qr["id"], format="svg", size=1000).save("menu.svg")
68
+ ```
69
+
70
+ ## Command line
71
+
72
+ The package installs the `bn` command: `bn login`, `bn shorten URL`, `bn links`, `bn stats CODE`, `bn check CODE`
73
+ (Launch Check, exit 1 when not ready), `bn qr URL -o file.svg`, `bn bulk file.csv`, `bn api GET /usage`. Run
74
+ `bn --help`. The same command is a single file at https://b1.lc/cli/bn.
75
+
76
+ ## Errors
77
+
78
+ ```python
79
+ from bn_shortener import ValidationError, RateLimitError, NotFoundError, APIError
80
+
81
+ try:
82
+ bn.links.create(url="not a url")
83
+ except ValidationError as e:
84
+ print(e.errors) # [{'field': 'url', 'code': ..., 'message': ...}]
85
+ except APIError as e:
86
+ print(e.status, e.type, e.detail, e.request_id) # e.type links to https://b1.lc/docs/errors/...
87
+ ```
88
+
89
+ Connection errors, timeouts, 408, 429 and 5xx are retried (`max_retries=2`, waiting as long as `Retry-After`
90
+ says). Every write carries an `Idempotency-Key`, so a retry after a lost answer never creates a second link.
91
+ Pass `request_options={"idempotency_key": "..."}` to make your own retries safe too.
92
+
93
+ ## Apps that act for other people (OAuth 2.1)
94
+
95
+ ```python
96
+ from bn_shortener import BNShortener
97
+ from bn_shortener.oauth import OAuthClient, TokenSession
98
+
99
+ app = OAuthClient("bnc_...", redirect_uri="https://example.com/callback")
100
+ auth = app.authorization_url(scope=["read", "write"]) # keep auth.state + auth.code_verifier in the session
101
+ # send the person to auth.url; b1.lc sends them back to your redirect URI
102
+ code = app.parse_callback(callback_url, expected_state=auth.state)
103
+ token = app.exchange_code(code, auth.code_verifier)
104
+
105
+ bn = BNShortener(oauth=TokenSession(app, token, on_refresh=save_token)) # refreshes by itself
106
+ ```
107
+
108
+ Command-line tools without a browser use the device flow:
109
+
110
+ ```python
111
+ device = app.start_device_flow(scope=["read", "write"])
112
+ print(f"Open {device.verification_uri} and type {device.user_code}")
113
+ token = app.wait_for_device(device)
114
+ ```
115
+
116
+ Refresh tokens rotate: save the new token in `on_refresh` every time.
117
+
118
+ ## Webhooks
119
+
120
+ ```python
121
+ from bn_shortener.webhooks import construct_event
122
+
123
+ event = construct_event(request.get_data(), request.headers, WEBHOOK_SECRET) # raises WebhookVerificationError
124
+ ```
125
+
126
+ Pass the raw body, before any JSON parsing. Signatures older than 5 minutes are refused (replays).
127
+
128
+ ## Options
129
+
130
+ `BNShortener(api_key, access_token=None, oauth=None, base_url="https://b1.lc/api/v1", timeout=30.0, max_retries=2,
131
+ client_name=None, default_headers=None, transport=None)`. `client_name` shows your integration by name in the
132
+ account's usage (`GET /usage`). `bn.last_response` has the status, request id and rate limit of the latest call.
133
+ `bn.request("GET", "/path")` calls an endpoint this version does not wrap yet.
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,119 @@
1
+ # bn-shortener (Python)
2
+
3
+ Official Python client for the [BN Shortener API](https://b1.lc/docs/) (b1.lc): short links, QR codes,
4
+ bio pages, analytics, Launch Check, webhooks and OAuth. No dependencies; Python 3.9+.
5
+
6
+ ```bash
7
+ pip install bn-shortener
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```python
13
+ from bn_shortener import BNShortener
14
+
15
+ bn = BNShortener("bn_live_...") # or set BN_API_KEY
16
+
17
+ link = bn.links.create(url="https://example.com/spring-sale", title="Spring sale")
18
+ print(link["short_url"]) # https://b1.lc/AbC9
19
+
20
+ stats = bn.links.stats(link["id"], period="30d")
21
+ print(stats["summary"]["total_clicks"])
22
+
23
+ for l in bn.paginate(bn.links.list, search="sale"): # every page
24
+ print(l["short_code"], l["click_count"])
25
+ ```
26
+
27
+ Create an API key in the dashboard: **API Portal → API keys**. A key has permissions (`read`, `write`, `delete`,
28
+ `webhooks`, `admin`); give each integration only what it needs.
29
+
30
+ ## Resources
31
+
32
+ | Attribute | What |
33
+ |---|---|
34
+ | `bn.links` | create, get, get_by_code, update, delete, archive, restore, clone, stats, clicks, referrers, devices, geo, timeline, export, import, bulk_create / bulk_update / bulk_delete, health, stream_clicks |
35
+ | `bn.qr_codes` | create, get, update, delete, list, image (png, jpeg, webp, svg, pdf, eps), stats |
36
+ | `bn.bio_pages` | create, get, update, delete, list, list_blocks, create_block, update_block, delete_block, reorder_blocks, analytics, subscribers, messages |
37
+ | `bn.webhooks` | create, get, update, delete, list, test, deliveries, get_delivery, redeliver, events |
38
+ | `bn.launch_check` | run, get, start_run, get_run, stop_run |
39
+ | `bn.security` | scan, check, get_scan, bulk_scan, list_scans, ... |
40
+ | `bn.messaging` | create_batch, list_batches, get_batch, list_messages, record_conversion |
41
+ | `bn.api_keys`, `bn.account`, `bn.app_links`, `bn.system` | keys, usage + audit log, apps, API info |
42
+
43
+ Every method's docstring names its endpoint and the permission it needs. Methods return the response's `data`;
44
+ list methods return a `Page` (iterate it; `page.pagination`, `page.next_page()`); files come back as
45
+ `BinaryResponse` (`.content`, `.content_type`, `.save(path)`):
46
+
47
+ ```python
48
+ qr = bn.qr_codes.create(name="Menu", content={"url": "https://example.com/menu"})
49
+ bn.qr_codes.image(qr["id"], format="svg", size=1000).save("menu.svg")
50
+ ```
51
+
52
+ ## Command line
53
+
54
+ The package installs the `bn` command: `bn login`, `bn shorten URL`, `bn links`, `bn stats CODE`, `bn check CODE`
55
+ (Launch Check, exit 1 when not ready), `bn qr URL -o file.svg`, `bn bulk file.csv`, `bn api GET /usage`. Run
56
+ `bn --help`. The same command is a single file at https://b1.lc/cli/bn.
57
+
58
+ ## Errors
59
+
60
+ ```python
61
+ from bn_shortener import ValidationError, RateLimitError, NotFoundError, APIError
62
+
63
+ try:
64
+ bn.links.create(url="not a url")
65
+ except ValidationError as e:
66
+ print(e.errors) # [{'field': 'url', 'code': ..., 'message': ...}]
67
+ except APIError as e:
68
+ print(e.status, e.type, e.detail, e.request_id) # e.type links to https://b1.lc/docs/errors/...
69
+ ```
70
+
71
+ Connection errors, timeouts, 408, 429 and 5xx are retried (`max_retries=2`, waiting as long as `Retry-After`
72
+ says). Every write carries an `Idempotency-Key`, so a retry after a lost answer never creates a second link.
73
+ Pass `request_options={"idempotency_key": "..."}` to make your own retries safe too.
74
+
75
+ ## Apps that act for other people (OAuth 2.1)
76
+
77
+ ```python
78
+ from bn_shortener import BNShortener
79
+ from bn_shortener.oauth import OAuthClient, TokenSession
80
+
81
+ app = OAuthClient("bnc_...", redirect_uri="https://example.com/callback")
82
+ auth = app.authorization_url(scope=["read", "write"]) # keep auth.state + auth.code_verifier in the session
83
+ # send the person to auth.url; b1.lc sends them back to your redirect URI
84
+ code = app.parse_callback(callback_url, expected_state=auth.state)
85
+ token = app.exchange_code(code, auth.code_verifier)
86
+
87
+ bn = BNShortener(oauth=TokenSession(app, token, on_refresh=save_token)) # refreshes by itself
88
+ ```
89
+
90
+ Command-line tools without a browser use the device flow:
91
+
92
+ ```python
93
+ device = app.start_device_flow(scope=["read", "write"])
94
+ print(f"Open {device.verification_uri} and type {device.user_code}")
95
+ token = app.wait_for_device(device)
96
+ ```
97
+
98
+ Refresh tokens rotate: save the new token in `on_refresh` every time.
99
+
100
+ ## Webhooks
101
+
102
+ ```python
103
+ from bn_shortener.webhooks import construct_event
104
+
105
+ event = construct_event(request.get_data(), request.headers, WEBHOOK_SECRET) # raises WebhookVerificationError
106
+ ```
107
+
108
+ Pass the raw body, before any JSON parsing. Signatures older than 5 minutes are refused (replays).
109
+
110
+ ## Options
111
+
112
+ `BNShortener(api_key, access_token=None, oauth=None, base_url="https://b1.lc/api/v1", timeout=30.0, max_retries=2,
113
+ client_name=None, default_headers=None, transport=None)`. `client_name` shows your integration by name in the
114
+ account's usage (`GET /usage`). `bn.last_response` has the status, request id and rate limit of the latest call.
115
+ `bn.request("GET", "/path")` calls an endpoint this version does not wrap yet.
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bn-shortener"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the BN Shortener API (b1.lc): links, QR codes, bio pages, analytics, webhooks, OAuth"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "BroadNet Technologies LLC", email = "support@broadnet.me" }]
13
+ keywords = ["url shortener", "short links", "qr codes", "b1.lc", "broadnet"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.scripts]
22
+ bn = "bn_shortener.cli:run"
23
+
24
+ [project.urls]
25
+ Homepage = "https://b1.lc"
26
+ Documentation = "https://b1.lc/docs/"
27
+ Source = "https://github.com/BN-Shortner/bn-shortener-python"
28
+ Issues = "https://github.com/BN-Shortner/bn-shortener-python/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/bn_shortener"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ addopts = "-q -p no:cacheprovider"
@@ -0,0 +1,13 @@
1
+ """BN Shortener API v3 client: https://b1.lc/docs/"""
2
+ from . import oauth, webhooks
3
+ from ._client import BNShortener, BinaryResponse, LastResponse, Page
4
+ from ._errors import (APIConnectionError, APIError, APITimeoutError, AuthenticationError, BadRequestError, BNError,
5
+ ConflictError, InternalServerError, NotFoundError, OAuthError, PermissionDeniedError,
6
+ RateLimitError, ValidationError, WebhookVerificationError)
7
+ from ._transport import Request, Response
8
+ from ._version import __version__
9
+
10
+ __all__ = ['BNShortener', 'Page', 'BinaryResponse', 'LastResponse', 'Request', 'Response', 'oauth', 'webhooks',
11
+ 'BNError', 'APIError', 'APIConnectionError', 'APITimeoutError', 'BadRequestError', 'AuthenticationError',
12
+ 'PermissionDeniedError', 'NotFoundError', 'ConflictError', 'ValidationError', 'RateLimitError',
13
+ 'InternalServerError', 'OAuthError', 'WebhookVerificationError', '__version__']
@@ -0,0 +1,3 @@
1
+ from .cli import run
2
+
3
+ run()
@@ -0,0 +1,299 @@
1
+ import json
2
+ import os
3
+ import platform
4
+ import random
5
+ import time
6
+ import uuid
7
+ from typing import Any, Callable, Dict, Iterator, Mapping, Optional
8
+ from urllib.parse import quote, urlencode
9
+
10
+ from . import _transport
11
+ from ._errors import APIConnectionError, APIError, AuthenticationError, BNError, OAuthError, error_for
12
+ from ._generated import GeneratedResources
13
+ from ._version import __version__
14
+
15
+ DEFAULT_BASE_URL = 'https://b1.lc/api/v1'
16
+ RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
17
+ WRITE_METHODS = {'POST', 'PUT', 'PATCH', 'DELETE'}
18
+ MAX_RETRY_WAIT = 60.0
19
+
20
+
21
+ class Page:
22
+ """One page of a list. Iterate it for the items; ``pagination`` has page, per_page, total_items, total_pages,
23
+ has_next, has_prev. ``client.paginate(...)`` walks every page for you."""
24
+
25
+ def __init__(self, data, meta, fetch):
26
+ self.data = list(data or [])
27
+ self.meta = meta or {}
28
+ self.pagination = self.meta.get('pagination') or {}
29
+ self._fetch = fetch
30
+
31
+ @property
32
+ def has_next(self):
33
+ p = self.pagination
34
+ if 'has_next' in p:
35
+ return bool(p['has_next'])
36
+ return bool(p.get('total_pages') and p.get('page', 1) < p['total_pages'])
37
+
38
+ def next_page(self):
39
+ """The following page, or None after the last one."""
40
+ if not self.has_next:
41
+ return None
42
+ return self._fetch(int(self.pagination.get('page', 1)) + 1)
43
+
44
+ def __iter__(self):
45
+ return iter(self.data)
46
+
47
+ def __len__(self):
48
+ return len(self.data)
49
+
50
+ def __getitem__(self, i):
51
+ return self.data[i]
52
+
53
+ def __repr__(self):
54
+ return f'<Page {self.pagination.get("page", "?")}/{self.pagination.get("total_pages", "?")}: {len(self.data)} items>'
55
+
56
+
57
+ class BinaryResponse:
58
+ """A file answer (QR code image, CSV export): ``content`` bytes plus ``content_type``, ``filename`` and headers."""
59
+
60
+ def __init__(self, content, headers):
61
+ self.content = content
62
+ self.headers = headers
63
+ self.content_type = headers.get('content-type', '').split(';')[0].strip()
64
+ cd = headers.get('content-disposition', '')
65
+ self.filename = cd.split('filename=', 1)[1].strip('"; ') if 'filename=' in cd else None
66
+
67
+ @property
68
+ def text(self):
69
+ return self.content.decode('utf-8')
70
+
71
+ def save(self, path):
72
+ with open(path, 'wb') as f:
73
+ f.write(self.content)
74
+ return path
75
+
76
+ def __repr__(self):
77
+ return f'<BinaryResponse {self.content_type} {len(self.content)} bytes>'
78
+
79
+
80
+ class LastResponse:
81
+ """Status, headers and rate limit of the latest answer (``client.last_response``)."""
82
+
83
+ def __init__(self, status, headers):
84
+ self.status = status
85
+ self.headers = headers
86
+ self.request_id = headers.get('x-request-id')
87
+
88
+ def num(name):
89
+ try:
90
+ return int(headers[name])
91
+ except (KeyError, ValueError):
92
+ return None
93
+ self.rate_limit = {'limit': num('x-ratelimit-limit'), 'remaining': num('x-ratelimit-remaining'),
94
+ 'reset': num('x-ratelimit-reset')}
95
+ self.idempotent_replay = headers.get('idempotency-replayed') == 'true' or headers.get('x-idempotency-replayed') == 'true'
96
+
97
+
98
+ def _clean(params):
99
+ out = {}
100
+ for k, v in (params or {}).items():
101
+ if v is None:
102
+ continue
103
+ if isinstance(v, bool):
104
+ v = 'true' if v else 'false'
105
+ elif isinstance(v, (list, tuple)):
106
+ v = ','.join(str(x) for x in v)
107
+ out[k] = v
108
+ return out
109
+
110
+
111
+ class BNShortener(GeneratedResources):
112
+ """Client for the BN Shortener API v3 (https://b1.lc/docs/).
113
+
114
+ Authenticate with an API key (``api_key=`` or the ``BN_API_KEY`` environment variable), an OAuth access token
115
+ (``access_token=``), or an ``oauth.TokenSession`` that refreshes itself (``oauth=``).
116
+
117
+ Every resource is an attribute: ``client.links``, ``client.qr_codes``, ``client.bio_pages``, ``client.webhooks``...
118
+ Writes get an Idempotency-Key automatically, so retries after a timeout or 5xx never do the work twice.
119
+ """
120
+
121
+ def __init__(self, api_key: Optional[str] = None, *, access_token: Optional[str] = None, oauth=None,
122
+ base_url: Optional[str] = None, timeout: float = 30.0, max_retries: int = 2,
123
+ client_name: Optional[str] = None, default_headers: Optional[Mapping[str, str]] = None,
124
+ transport: Optional[Callable[[_transport.Request], _transport.Response]] = None):
125
+ if not (api_key or access_token or oauth):
126
+ api_key = os.environ.get('BN_API_KEY')
127
+ if not (api_key or access_token or oauth):
128
+ raise BNError('Pass api_key=, access_token= or oauth= (or set BN_API_KEY)')
129
+ self.api_key = api_key
130
+ self.access_token = access_token
131
+ self.oauth = oauth
132
+ self.base_url = (base_url or os.environ.get('BN_BASE_URL') or DEFAULT_BASE_URL).rstrip('/')
133
+ self.timeout = timeout
134
+ self.max_retries = max(0, int(max_retries))
135
+ self.client_name = client_name
136
+ self.default_headers = dict(default_headers or {})
137
+ self.transport = transport or _transport.urllib_transport
138
+ self.last_response: Optional[LastResponse] = None
139
+ self._sleep = time.sleep
140
+ self._init_resources()
141
+
142
+ # ---- public helpers ----------------------------------------------------------------------------------------
143
+ def paginate(self, method, *args, **kwargs) -> Iterator[Any]:
144
+ """Every item of a list method, page after page: ``for link in client.paginate(client.links.list, search='x')``."""
145
+ page = method(*args, **kwargs)
146
+ while page is not None:
147
+ yield from page
148
+ page = page.next_page()
149
+
150
+ def request(self, method: str, path: str, *, query=None, body=None, idempotency_key=None, headers=None):
151
+ """Call an endpoint this version does not wrap yet. ``path`` is relative to base_url (e.g. '/links')."""
152
+ return self._request(method.upper(), path, query=query, body=body,
153
+ options={'idempotency_key': idempotency_key, 'headers': headers})
154
+
155
+ # ---- internals ---------------------------------------------------------------------------------------------
156
+ def _headers(self, options):
157
+ h = {'Accept': 'application/json', 'User-Agent': f'bn-shortener-python/{__version__} (Python {platform.python_version()})',
158
+ 'X-BN-Client': self.client_name or f'bn-python/{__version__}'}
159
+ h.update(self.default_headers)
160
+ if self.oauth is not None:
161
+ try:
162
+ h['Authorization'] = 'Bearer ' + self.oauth.get_access_token()
163
+ except OAuthError as e:
164
+ if e.ended: # disconnected, revoked, expired: the app must connect again
165
+ raise AuthenticationError(401, {'title': 'Authentication Required',
166
+ 'detail': f'Could not refresh the access token ({e})'}) from e
167
+ raise APIConnectionError(f'The token endpoint is unavailable ({e}); try again') from e
168
+ elif self.access_token:
169
+ h['Authorization'] = 'Bearer ' + self.access_token
170
+ else:
171
+ h['X-API-Key'] = self.api_key
172
+ h.update((options or {}).get('headers') or {})
173
+ return h
174
+
175
+ def _url(self, path, path_params=None, query=None):
176
+ for k, v in (path_params or {}).items():
177
+ path = path.replace('{' + k + '}', quote(str(v), safe=''))
178
+ q = _clean(query)
179
+ return self.base_url + path + ('?' + urlencode(q) if q else '')
180
+
181
+ def _retry_wait(self, attempt, headers):
182
+ ra = headers.get('retry-after') if headers else None
183
+ if ra:
184
+ try:
185
+ return min(MAX_RETRY_WAIT, max(0.0, float(ra)))
186
+ except ValueError:
187
+ pass
188
+ return min(8.0, 0.5 * (2 ** attempt)) * (0.75 + random.random() / 2)
189
+
190
+ def _send(self, method, url, body, options, stream=False):
191
+ options = options or {}
192
+ data = None
193
+ idem = None
194
+ if body is not None:
195
+ data = json.dumps(body, default=str).encode('utf-8')
196
+ if method in WRITE_METHODS:
197
+ idem = options.get('idempotency_key') or f'bn-sdk-{uuid.uuid4().hex}'
198
+ timeout = options.get('timeout') or self.timeout
199
+ retried_auth = False
200
+ attempt = 0
201
+ while True:
202
+ headers = self._headers(options)
203
+ if data is not None:
204
+ headers['Content-Type'] = 'application/json'
205
+ if idem:
206
+ headers['Idempotency-Key'] = idem
207
+ try:
208
+ resp = self.transport(_transport.Request(method, url, headers, data, timeout, stream))
209
+ except APIConnectionError:
210
+ if attempt < self.max_retries:
211
+ self._sleep(self._retry_wait(attempt, None))
212
+ attempt += 1
213
+ continue
214
+ raise
215
+ self.last_response = LastResponse(resp.status, resp.headers)
216
+ if resp.status == 401 and self.oauth is not None and not retried_auth and self.oauth.can_refresh():
217
+ retried_auth = True # the access token may have been revoked or expired early: refresh once
218
+ try:
219
+ self.oauth.refresh_now(only_if=headers['Authorization'][len('Bearer '):])
220
+ except OAuthError as e:
221
+ if e.ended:
222
+ return resp # the sign-in itself ended: the caller gets the 401 as AuthenticationError
223
+ raise APIConnectionError(f'The token endpoint is unavailable ({e}); try again') from e
224
+ resp.close()
225
+ continue
226
+ # 409 + Retry-After = our earlier try with this Idempotency-Key is still running (e.g. after a timeout):
227
+ # asking again shortly returns its stored answer. A real conflict (code taken) has no Retry-After.
228
+ in_flight = resp.status == 409 and idem and 'retry-after' in resp.headers
229
+ if (resp.status in RETRY_STATUSES or in_flight) and attempt < self.max_retries:
230
+ resp.close()
231
+ self._sleep(self._retry_wait(attempt, resp.headers))
232
+ attempt += 1
233
+ continue
234
+ return resp
235
+
236
+ def _raise_for(self, resp):
237
+ body = resp.body
238
+ if resp.lines is not None:
239
+ body = b''.join(resp.lines)
240
+ try:
241
+ problem = json.loads(body.decode('utf-8')) if body else {}
242
+ except ValueError:
243
+ problem = {'title': f'HTTP {resp.status}', 'detail': body[:200].decode('utf-8', 'replace')}
244
+ raise error_for(resp.status, problem, resp.headers)
245
+
246
+ def _request(self, method, path, *, path_params=None, query=None, body=None, paged=False, options=None):
247
+ url = self._url(path, path_params, query)
248
+ resp = self._send(method, url, body, options)
249
+ if resp.status >= 300: # the API never redirects; a 3xx means a proxy or wrong base_url
250
+ self._raise_for(resp)
251
+ ctype = resp.headers.get('content-type', '')
252
+ if 'json' not in ctype:
253
+ if not resp.body and resp.status == 204:
254
+ return None
255
+ return BinaryResponse(resp.body, resp.headers)
256
+ payload = json.loads(resp.body.decode('utf-8')) if resp.body else None
257
+ if isinstance(payload, dict) and 'success' in payload and 'data' in payload:
258
+ if paged:
259
+ def fetch(n):
260
+ return self._request(method, path, path_params=path_params, query={**(query or {}), 'page': n},
261
+ paged=True, options=options)
262
+ return Page(payload['data'], payload.get('meta'), fetch)
263
+ return payload['data']
264
+ return payload
265
+
266
+ def _stream(self, path, *, path_params=None, query=None, options=None):
267
+ """Server-sent events as dicts. Heartbeats are skipped; the stream ends when the server closes it."""
268
+ options = dict(options or {})
269
+ options.setdefault('headers', {})
270
+ options['headers'] = {**options['headers'], 'Accept': 'text/event-stream'}
271
+ resp = self._send('GET', self._url(path, path_params, query), None, options, stream=True)
272
+ if resp.status >= 400:
273
+ self._raise_for(resp)
274
+ event, data = None, []
275
+ try:
276
+ for raw in resp.lines:
277
+ line = raw.decode('utf-8').rstrip('\r\n')
278
+ if not line:
279
+ if data:
280
+ text = '\n'.join(data)
281
+ try:
282
+ item = json.loads(text)
283
+ except ValueError:
284
+ item = {'data': text}
285
+ if event and isinstance(item, dict):
286
+ item.setdefault('event', event)
287
+ yield item
288
+ event, data = None, []
289
+ elif line.startswith(':'):
290
+ continue
291
+ elif line.startswith('event:'):
292
+ event = line[6:].strip()
293
+ elif line.startswith('data:'):
294
+ data.append(line[5:].lstrip())
295
+ finally:
296
+ resp.close()
297
+
298
+
299
+ __all__ = ['BNShortener', 'Page', 'BinaryResponse', 'LastResponse', 'APIError', 'AuthenticationError']