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.
- bn_shortener-0.1.0/LICENSE +21 -0
- bn_shortener-0.1.0/PKG-INFO +137 -0
- bn_shortener-0.1.0/README.md +119 -0
- bn_shortener-0.1.0/pyproject.toml +35 -0
- bn_shortener-0.1.0/src/bn_shortener/__init__.py +13 -0
- bn_shortener-0.1.0/src/bn_shortener/__main__.py +3 -0
- bn_shortener-0.1.0/src/bn_shortener/_client.py +299 -0
- bn_shortener-0.1.0/src/bn_shortener/_errors.py +106 -0
- bn_shortener-0.1.0/src/bn_shortener/_generated.py +1844 -0
- bn_shortener-0.1.0/src/bn_shortener/_transport.py +80 -0
- bn_shortener-0.1.0/src/bn_shortener/_version.py +1 -0
- bn_shortener-0.1.0/src/bn_shortener/cli.py +835 -0
- bn_shortener-0.1.0/src/bn_shortener/oauth.py +221 -0
- bn_shortener-0.1.0/src/bn_shortener/py.typed +0 -0
- bn_shortener-0.1.0/src/bn_shortener/webhooks.py +67 -0
- bn_shortener-0.1.0/tests/test_client.py +139 -0
|
@@ -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,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']
|